plotty 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotty
3
+ Version: 1.0.0
4
+ Summary: Inline matplotlib plots in your terminal via sixel, in a tmux pane, over SSH
5
+ Author-email: xuesoso <xuesoso@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/xuesoso/plotty
8
+ Project-URL: Repository, https://github.com/xuesoso/plotty
9
+ Keywords: matplotlib,sixel,tmux,ssh,terminal,plotting,repl
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Framework :: Matplotlib
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Visualization
18
+ Requires-Python: >=3.7
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: matplotlib>=3.5
22
+ Requires-Dist: numpy>=1.17
23
+ Dynamic: license-file
24
+
25
+ # plotty
26
+
27
+ [![CI](https://github.com/xuesoso/plotty/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/xuesoso/plotty/actions/workflows/ci.yml)
28
+ [![security: pip-audit](https://img.shields.io/badge/security-pip--audit-blue.svg)](https://github.com/xuesoso/plotty/actions/workflows/ci.yml)
29
+ [![Python](https://img.shields.io/badge/python-3.7%2B-blue.svg)](pyproject.toml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
31
+
32
+ > Inline matplotlib plots in your terminal — rendered as **sixel** in a dedicated
33
+ > tmux pane, **including over SSH**. No browser, no X11, no Jupyter server.
34
+
35
+ <p align="center">
36
+ <img src="images/plotty_1.gif" alt="plotty demo" width="720">
37
+ </p>
38
+
39
+ `plotty` is a matplotlib backend that draws figures directly in your terminal, so
40
+ a `tmux` + `ipython` (+ `nvim`) workflow shows plots the way a Jupyter or VS Code
41
+ notebook does. Activate it once and your figures appear in a tmux pane next to
42
+ your REPL — locally or on a remote machine over SSH. It's inspired by and the Python analogue of
43
+ [MuxDisplay.jl](https://github.com/goerz/MuxDisplay.jl).
44
+
45
+ ```python
46
+ import plotty
47
+ plotty.enable()
48
+
49
+ import matplotlib.pyplot as plt
50
+ plt.plot([1, 4, 9, 16]) # shows up in the plot pane
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Why / when to use it
56
+
57
+ If you do interactive analysis in a terminal — `ipython` inside `tmux`, editing
58
+ in `nvim`, frequently SSH'd into a remote box — you normally lose inline plots:
59
+ `plt.show()` wants a GUI and Jupyter wants a browser. plotty fills that gap and
60
+ covers three setups:
61
+
62
+ - **Local tmux.** Run your REPL in one pane; plots render in another.
63
+ - **Remote over SSH.** Run everything on the remote inside tmux. Only the
64
+ rendered **sixel bytes** cross the wire (drawn by your local terminal); the
65
+ control plane — signals, pidfile, image hand-off — stays host-local, so it
66
+ behaves exactly like a local session.
67
+ - **Nested tmux** (`local tmux → ssh → remote tmux`). Supported with a small,
68
+ one-time tmux config change — see [Nested tmux](#nested-tmux-local--remote).
69
+
70
+ ## Requirements
71
+
72
+ | | |
73
+ |---|---|
74
+ | **Python** | ≥ 3.7 |
75
+ | **tmux** | ≥ **3.4**, built with sixel support (`--enable-sixel`) |
76
+ | **Terminal** | a sixel-capable terminal for display — e.g. WezTerm, foot, Konsole, `xterm -ti vt340` |
77
+ | **Python deps** | `matplotlib` (and `numpy`, which ships with matplotlib) — that's all |
78
+
79
+ Check tmux:
80
+
81
+ ```bash
82
+ tmux -V # need >= 3.4
83
+ strings "$(command -v tmux)" | grep -qi sixel && echo "sixel: yes" || echo "sixel: MISSING"
84
+ ```
85
+
86
+ > Not in tmux? plotty falls back to writing sixel straight to your terminal's
87
+ > stdout, so it still works in any sixel-capable terminal without tmux.
88
+
89
+ ## Install
90
+
91
+ plotty installs with uv (which indexes PyPI) or pip:
92
+
93
+ ```bash
94
+ uv add plotty # add to your project (resolved + locked)
95
+ # or
96
+ uv pip install plotty # into the active environment
97
+ # or
98
+ pip install plotty
99
+ ```
100
+
101
+ From source:
102
+
103
+ ```bash
104
+ git clone https://github.com/xuesoso/plotty && cd plotty
105
+ uv pip install .
106
+ ```
107
+
108
+ ## Quick start
109
+
110
+ ```python
111
+ import plotty
112
+ plotty.enable() # auto-detect a renderer, target the last tmux pane,
113
+ # and spawn a tiny viewer there
114
+
115
+ import matplotlib.pyplot as plt
116
+ plt.plot([1, 4, 9, 16])
117
+ # IPython: the figure appears automatically after each cell.
118
+ # Plain REPL: call plt.show().
119
+
120
+ plotty.disable() # stop the viewer and restore matplotlib
121
+ ```
122
+
123
+ Inside tmux, plotty draws into the **last pane** of the current window by default,
124
+ so split a pane first (`Ctrl-b "`), then call `enable()`. Target another pane with
125
+ `enable(target_pane=...)`.
126
+
127
+ Public API: `enable()`, `disable()`, `redraw()`, `view()`.
128
+
129
+ ### Demo
130
+
131
+ Run the bundled example to see it in action (split off a plot pane first, then
132
+ `python examples/demo.py`). The GIF below is the expected output:
133
+
134
+ ```bash
135
+ python examples/demo.py
136
+ ```
137
+
138
+ <p align="center">
139
+ <img src="images/plotty_2.gif" alt="plotty rendering the examples/demo.py plots in a tmux pane" width="720">
140
+ </p>
141
+
142
+ ## How it works
143
+
144
+ Two cooperating pieces share state via the filesystem + OS signals:
145
+
146
+ - **Backend** (`module://plotty`, runs in your REPL): on each figure it saves a
147
+ PNG, atomically publishes it to `~/.cache/plotty/last.png`, and signals the
148
+ viewer.
149
+ - **Viewer** (runs in the plot pane): redraws on a new figure (`SIGUSR1`) and on
150
+ pane resize/zoom (`SIGWINCH`). It's event-driven (`signal.pause()`), idle at
151
+ zero CPU, and self-cleaning.
152
+
153
+ Because only sixel bytes cross SSH and everything else is host-local, remote use
154
+ is identical to local.
155
+
156
+ ## Display modes
157
+
158
+ - **Viewer mode** (default in tmux) — a small viewer process lives in the target
159
+ pane and redraws on new figures *and* on pane resize/zoom. Recommended; it's
160
+ the mode that survives resizing.
161
+ - **Inline mode** (default outside tmux, or `enable(inline=True)`) — the backend
162
+ renders sixel itself, with no helper process, and writes it to the target
163
+ pane's tty (in tmux) or to your stdout (no tmux). It does **not** auto-redraw
164
+ on resize.
165
+
166
+ ```python
167
+ plotty.enable(inline=True) # force inline even inside tmux
168
+ ```
169
+
170
+ ## Sixel encoders
171
+
172
+ plotty ships with a **built-in, dependency-free sixel encoder** (pure stdlib +
173
+ numpy), so it works out of the box with no external tools.
174
+
175
+ If one is on your `PATH`, plotty auto-detects an external encoder for
176
+ higher-quality (dithered) output, in priority order:
177
+
178
+ 1. [`chafa`](https://github.com/hpjansson/chafa) — recommended
179
+ 2. [`img2sixel`](https://github.com/saitoha/libsixel) (libsixel)
180
+ 3. ImageMagick (`magick` / `convert`)
181
+
182
+ Force the built-in encoder regardless of what's installed:
183
+
184
+ ```python
185
+ plotty.enable(imgcat="builtin") # or: PLOTTY_IMGCAT=builtin
186
+ ```
187
+
188
+ > plotty is **sixel-only** by design — sixel is the only path that survives tmux
189
+ > and SSH. Non-sixel terminal-image protocols (kitty / iTerm) are not used. A
190
+ > custom non-sixel `imgcat=` may be passed but will warn that it may not display
191
+ > over SSH.
192
+
193
+ ## tmux configuration
194
+
195
+ plotty works with no config on a single tmux as long as tmux is ≥ 3.4 with sixel
196
+ and your terminal supports sixel (i.e. Wezterm, iTerm2, xterm, xfce term, VSCode). Reference [Are We Sixel Yet?](https://www.arewesixelyet.com/) for a complete list. If plots don't appear (or you see raw
197
+ escape-sequence junk instead of an image), tmux hasn't recognized that your
198
+ terminal can render sixel — its auto-detection isn't always reliable, especially
199
+ over SSH. Tell it explicitly in `~/.tmux.conf`:
200
+
201
+ ```tmux
202
+ set -as terminal-features ',*:sixel'
203
+ ```
204
+
205
+ ### Nested tmux (local + remote)
206
+
207
+ A common remote setup is a tmux **inside** a tmux:
208
+
209
+ ```
210
+ local terminal → local tmux → ssh → remote tmux → REPL + plot pane
211
+ ```
212
+
213
+ For the image to flow all the way out, **every** tmux layer must render and
214
+ forward the sixel — which means setting the feature on **both** the local and the
215
+ remote tmux:
216
+
217
+ ```tmux
218
+ # add to ~/.tmux.conf on BOTH the local laptop and the remote machine
219
+ set -as terminal-features ',*:sixel'
220
+ ```
221
+
222
+ Without this, the inner (remote) tmux doesn't know to forward sixel and the raw
223
+ escape sequence leaks through as garbage characters. Verify a layer sees the
224
+ feature with:
225
+
226
+ ```bash
227
+ tmux display-message -p '#{client_termfeatures}' # should contain "sixel"
228
+ ```
229
+
230
+ Both tmux layers must be ≥ 3.4 and built with sixel.
231
+
232
+ ## Configuration reference
233
+
234
+ `enable()` arguments (each has an environment-variable default):
235
+
236
+ | argument | env var | default | meaning |
237
+ |---|---|---|---|
238
+ | `target_pane` | `PLOTTY_PANE` | `-1` | tmux pane for the plot; negative indexes from the end (`-1` = last) |
239
+ | `size` | `PLOTTY_SIZE` | `60` | display width in terminal cells |
240
+ | `dpi` | `PLOTTY_DPI` | matplotlib default | `savefig` DPI of the source image (raise it for sharper plots at large `size`) |
241
+ | `imgcat` | `PLOTTY_IMGCAT` | auto | renderer command; `"builtin"` forces the built-in encoder |
242
+ | `inline` | `PLOTTY_INLINE` | auto | `True`/`False` to force inline vs viewer-pane mode |
243
+ | `clear` | `PLOTTY_CLEAR` | `True` | clear the pane before each draw |
244
+ | `close` | `PLOTTY_CLOSE` | `True` | close figures after display |
245
+ | `tmux` | `PLOTTY_TMUX` | `tmux` | tmux binary to use |
246
+ | `viewer` | — | `True` | spawn the viewer process (tmux mode) |
247
+ | `verbose` | — | `1` | print startup health-check warnings |
248
+ | — | `PLOTTY_CACHE` | `~/.cache/plotty` | state directory (`last.png`, pidfile) |
249
+
250
+ `size` and `dpi` are independent: `size` is how wide the image is *displayed*,
251
+ `dpi` is how many pixels the *source* has. For a crisp image at a large `size`,
252
+ raise `dpi` so the source has enough pixels.
253
+
254
+ ## Troubleshooting
255
+
256
+ - **Garbage / `+++` instead of an image:** a tmux layer isn't forwarding sixel.
257
+ Add `set -as terminal-features ',*:sixel'` to that layer (both layers if
258
+ nested) and confirm tmux ≥ 3.4 with sixel.
259
+ - **Nothing appears:** check `tmux -V` ≥ 3.4 and sixel support
260
+ (`strings $(command -v tmux) | grep -i sixel`); confirm your terminal supports
261
+ sixel; run `plotty.enable(verbose=1)` to print diagnostics.
262
+ - **Image too large / small:** tune `size`. Blurry when enlarged? raise `dpi`.
263
+ - **Plot doesn't refresh when you resize the pane:** use viewer mode (the default
264
+ in tmux); inline mode doesn't auto-redraw on resize.
265
+
266
+ ## License
267
+
268
+ MIT
@@ -0,0 +1,7 @@
1
+ plotty.py,sha256=dfA3ZYnpPaNaBHyYFotmJp9lr0vt_ZnhAItN4_StLZE,25144
2
+ plotty-1.0.0.dist-info/licenses/LICENSE,sha256=lLq3pP8a9jexuT52pDGAvfChpufW-jP-QPX_VdFrKn8,1064
3
+ plotty-1.0.0.dist-info/METADATA,sha256=J79nfKAQYqt6W-NgZYrEB1hvkL_DW946fFrBywEt6mw,10187
4
+ plotty-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ plotty-1.0.0.dist-info/entry_points.txt,sha256=HeptIZQecXfmf3N29ImKsuTiWft_-KO4brpVb_Nqe-o,44
6
+ plotty-1.0.0.dist-info/top_level.txt,sha256=UTVyqN_dEdenJBF20kmMjHgFtVfz9QybQB4iCYKgBCQ,7
7
+ plotty-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plotty-view = plotty:view
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xuesoso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ plotty
plotty.py ADDED
@@ -0,0 +1,731 @@
1
+ """
2
+ plotty - display matplotlib figures in a dedicated tmux pane (plot + tty).
3
+
4
+ The Python/Jupyter analogue of MuxDisplay.jl, built for SSH + tmux. The backend
5
+ (this module) runs in your REPL; a tiny viewer runs in the plot pane and redraws
6
+ on SIGUSR1 (new figure) and SIGWINCH (pane resize/zoom). Only the rendered sixel
7
+ bytes cross SSH, so it works the same locally and over a remote session.
8
+
9
+ import plotty
10
+ plotty.enable() # auto-detects renderer + last pane
11
+ plotty.enable(target_pane=2) # or pick a pane explicitly
12
+ plotty.disable() # stop the viewer + auto-display
13
+
14
+ Renderer auto-detection is sixel-only (the SSH-robust path): chafa, img2sixel,
15
+ ImageMagick. If none is on PATH it falls back to a built-in, dependency-free
16
+ sixel encoder (stdlib + numpy, which ships with matplotlib). A non-sixel command
17
+ may be passed explicitly as imgcat= but warns that it may not work over SSH.
18
+
19
+ Display modes: a viewer process running in a tmux pane (default in tmux), or
20
+ "inline" mode which renders sixel itself (no viewer) and writes it to the target
21
+ pane's tty when in tmux, or to the current terminal's stdout when not. Choose
22
+ with enable(inline=...) / PLOTTY_INLINE.
23
+
24
+ To rename this package, just rename the file: the matplotlib backend string is
25
+ derived from the module name automatically.
26
+
27
+ Config via env vars (optional; enable() args override): PLOTTY_PANE,
28
+ PLOTTY_IMGCAT, PLOTTY_CLEAR, PLOTTY_TMUX, PLOTTY_DPI, PLOTTY_CLOSE, PLOTTY_CACHE,
29
+ PLOTTY_SIZE, PLOTTY_INLINE.
30
+ """
31
+
32
+ import os
33
+ import re
34
+ import sys
35
+ import signal
36
+ import shlex
37
+ import shutil
38
+ import tempfile
39
+ import itertools
40
+ import subprocess
41
+
42
+ import numpy as np
43
+ import matplotlib
44
+ from matplotlib import image as mpimg
45
+ from matplotlib._pylab_helpers import Gcf
46
+ from matplotlib.backends import backend_agg
47
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
48
+ from matplotlib.figure import Figure
49
+
50
+ _ENV = "PLOTTY" # env var prefix (kept stable even if the file is renamed)
51
+
52
+ # Sixel renderer candidates, in priority order (first one found on PATH wins).
53
+ # Sixel is the only SSH-robust path, so non-sixel protocols (kitty/iTerm) are
54
+ # intentionally excluded. Placeholders are substituted at render time:
55
+ # "{}" -> the image path (else it's appended)
56
+ # "{size}" -> display width in terminal cells (_cfg["size"])
57
+ # "{width}" -> display width in pixels (size cells * pane cell width)
58
+ _CANDIDATES = [
59
+ "chafa -f sixels --size {size}", # sizes in cells
60
+ "img2sixel -w {width}", # sizes in pixels
61
+ "magick {} -resize {width}x sixel:-", # sizes in pixels
62
+ "convert {} -resize {width}x sixel:-",
63
+ ]
64
+
65
+
66
+ def _env(key, default):
67
+ return os.environ.get(f"{_ENV}_{key}", default)
68
+
69
+
70
+ _cfg = {
71
+ "pane": _env("PANE", "-1"),
72
+ "imgcat": _env("IMGCAT", None), # None -> auto-detect / built-in encoder
73
+ "clear": _env("CLEAR", "1") != "0",
74
+ "tmux": _env("TMUX", "tmux"),
75
+ "dpi": _env("DPI", None),
76
+ "close": _env("CLOSE", "1") != "0",
77
+ "size": _env("SIZE", "60"), # max display width in terminal cells
78
+ "inline": False, # set in enable(): True when not in tmux
79
+ }
80
+
81
+ _cache = os.path.expanduser(_env("CACHE", "~/.cache/plotty"))
82
+ os.makedirs(_cache, exist_ok=True)
83
+ _last = os.path.join(_cache, "last.png")
84
+ _pidfile = os.path.join(_cache, "viewer.pid")
85
+
86
+ _tmpdir = tempfile.mkdtemp(prefix="plotty-")
87
+ _counter = itertools.count()
88
+ _recent = []
89
+ _KEEP = 8
90
+
91
+
92
+ # ---- renderer detection -----------------------------------------------------
93
+
94
+ def _is_sixel(cmd):
95
+ return bool(cmd) and "sixel" in cmd.lower()
96
+
97
+
98
+ def _auto_imgcat():
99
+ """Return the first renderer command available on PATH, else None."""
100
+ for cmd in _CANDIDATES:
101
+ if shutil.which(shlex.split(cmd)[0]):
102
+ return cmd
103
+ return None
104
+
105
+
106
+ def _fmt(cmd, path):
107
+ q = shlex.quote(path)
108
+ return cmd.replace("{}", q) if "{}" in cmd else f"{cmd} {q}"
109
+
110
+
111
+ def _resolve_cmd(cmd, fd):
112
+ """Fill renderer size placeholders: {size}=width in cells, {width}=pixels.
113
+
114
+ {width} is derived from the target terminal (fd) so pixel-based renderers
115
+ follow `size` too. Renderers without either placeholder are left untouched.
116
+ """
117
+ if not cmd:
118
+ return cmd
119
+ if "{size}" in cmd:
120
+ cmd = cmd.replace("{size}", str(int(_cfg["size"])))
121
+ if "{width}" in cmd:
122
+ cmd = cmd.replace("{width}", str(_target_px_width(fd)))
123
+ return cmd
124
+
125
+
126
+ # ---- built-in sixel encoder (dependency-free fallback) ----------------------
127
+ #
128
+ # Used when no external renderer (chafa/img2sixel/magick) is on PATH. The Agg
129
+ # canvas gives us RGBA pixels and numpy ships with matplotlib, so we can encode
130
+ # sixel ourselves and honour the "stdlib + matplotlib only" rule with no extra
131
+ # runtime dependency. External renderers (when present) stay the preferred path
132
+ # because they dither for higher quality.
133
+
134
+ def _out_fd():
135
+ try:
136
+ return sys.stdout.fileno()
137
+ except (AttributeError, OSError, ValueError):
138
+ return 1
139
+
140
+
141
+ def _winsize(fd):
142
+ """Return (cols, rows, xpixels, ypixels); pixels are 0 if unreported."""
143
+ try:
144
+ import fcntl
145
+ import struct
146
+ import termios
147
+ packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8)
148
+ rows, cols, xpix, ypix = struct.unpack("HHHH", packed)
149
+ return cols, rows, xpix, ypix
150
+ except Exception:
151
+ cs = shutil.get_terminal_size((80, 24))
152
+ return cs.columns, cs.lines, 0, 0
153
+
154
+
155
+ def _target_px_width(fd):
156
+ """Target display width in pixels: `size` cells, capped to the pane width."""
157
+ cols, rows, xpix, ypix = _winsize(fd)
158
+ size = int(_cfg["size"])
159
+ cell_w = (xpix / cols) if (xpix and cols) else 10.0
160
+ target_cols = min(size, cols) if cols else size # never wider than the pane
161
+ return max(1, round(target_cols * cell_w))
162
+
163
+
164
+ def _target_size(fd, w, h):
165
+ """Pixel size to render at: scale to `size` cells wide, fit within the pane.
166
+
167
+ `size` cells map to pixels via the terminal's reported cell size (or a 10x20
168
+ guess when unreported, common in tmux) and scale the image up *or* down to
169
+ that width; the result is then bounded by the pane height.
170
+ """
171
+ cols, rows, xpix, ypix = _winsize(fd)
172
+ cell_h = (ypix / rows) if (ypix and rows) else 20.0
173
+ scale = _target_px_width(fd) / w
174
+ max_h = max((rows or 24) - 1, 1) * cell_h
175
+ if h * scale > max_h: # don't overflow the pane height
176
+ scale = max_h / h
177
+ return max(1, round(w * scale)), max(1, round(h * scale))
178
+
179
+
180
+ def _load_rgb(path):
181
+ """Read a PNG into an (H, W, 3) uint8 array, compositing alpha over white."""
182
+ a = mpimg.imread(path) # matplotlib reads PNG w/o Pillow
183
+ if a.ndim == 2:
184
+ a = np.stack([a] * 3, axis=-1)
185
+ if np.issubdtype(a.dtype, np.floating):
186
+ a = (a * 255.0).round().clip(0, 255).astype(np.uint8)
187
+ else:
188
+ a = a.astype(np.uint8)
189
+ if a.shape[2] == 4:
190
+ alpha = a[..., 3:4].astype(np.float32) / 255.0
191
+ rgb = a[..., :3].astype(np.float32)
192
+ a = (rgb * alpha + 255.0 * (1.0 - alpha)).round().astype(np.uint8)
193
+ return np.ascontiguousarray(a[..., :3])
194
+
195
+
196
+ def _resize(img, tw, th):
197
+ """Nearest-neighbour resample to (th, tw)."""
198
+ h, w = img.shape[:2]
199
+ if tw == w and th == h:
200
+ return img
201
+ ys = np.clip(np.arange(th) * h // th, 0, h - 1)
202
+ xs = np.clip(np.arange(tw) * w // tw, 0, w - 1)
203
+ return img[ys][:, xs]
204
+
205
+
206
+ def _make_box(pixels, ids):
207
+ px = pixels[ids]
208
+ rng = px.max(axis=0).astype(np.int32) - px.min(axis=0).astype(np.int32)
209
+ ch = int(rng.argmax())
210
+ return (ids, int(rng[ch]), ch)
211
+
212
+
213
+ def _quantize(rgb, ncolors=256):
214
+ """Median-cut quantization. Returns (palette (K,3) uint8, indices (N,) int)."""
215
+ pixels = rgb.reshape(-1, 3)
216
+ boxes = [_make_box(pixels, np.arange(pixels.shape[0]))]
217
+ while len(boxes) < ncolors:
218
+ si, best = -1, 0
219
+ for i, (ids, rng, _) in enumerate(boxes):
220
+ if ids.size > 1 and rng > best:
221
+ si, best = i, rng
222
+ if si < 0 or best == 0:
223
+ break # all boxes are single-colour
224
+ ids, _, ch = boxes.pop(si)
225
+ ids = ids[np.argsort(pixels[ids, ch], kind="stable")]
226
+ mid = ids.size // 2
227
+ boxes.append(_make_box(pixels, ids[:mid]))
228
+ boxes.append(_make_box(pixels, ids[mid:]))
229
+ palette = np.empty((len(boxes), 3), np.uint8)
230
+ indices = np.empty(pixels.shape[0], np.int32)
231
+ for i, (ids, _, _) in enumerate(boxes):
232
+ palette[i] = pixels[ids].mean(axis=0).round().astype(np.uint8)
233
+ indices[ids] = i
234
+ return palette, indices
235
+
236
+
237
+ def _rle(codes):
238
+ """Run-length encode a 1-D array of sixel byte values (already offset by 63)."""
239
+ n = codes.shape[0]
240
+ if n == 0:
241
+ return b""
242
+ change = np.ones(n, dtype=bool)
243
+ change[1:] = codes[1:] != codes[:-1]
244
+ starts = np.flatnonzero(change)
245
+ runs = np.diff(np.append(starts, n))
246
+ out = bytearray()
247
+ for val, run in zip(codes[starts].tolist(), runs.tolist()):
248
+ if run > 3:
249
+ out += b"!%d%c" % (run, val)
250
+ else:
251
+ out += bytes([val]) * run
252
+ return bytes(out)
253
+
254
+
255
+ def _sixel_bytes(palette, indices, h, w):
256
+ """Assemble a DCS sixel stream from a palette + per-pixel palette indices."""
257
+ idx = indices.reshape(h, w)
258
+ out = bytearray(b"\x1bPq")
259
+ out += b'"1;1;%d;%d' % (w, h) # raster attributes
260
+ for i, c in enumerate(palette): # palette, scaled to 0-100
261
+ out += b"#%d;2;%d;%d;%d" % (
262
+ i,
263
+ round(int(c[0]) * 100 / 255),
264
+ round(int(c[1]) * 100 / 255),
265
+ round(int(c[2]) * 100 / 255),
266
+ )
267
+ first_band = True
268
+ for top in range(0, h, 6): # 6 pixel rows per sixel band
269
+ if not first_band:
270
+ out += b"-" # next band
271
+ first_band = False
272
+ band = idx[top:top + 6]
273
+ bh = band.shape[0]
274
+ first_color = True
275
+ for ci in np.unique(band):
276
+ if not first_color:
277
+ out += b"$" # overlay next colour on band
278
+ first_color = False
279
+ out += b"#%d" % int(ci)
280
+ eq = band == ci
281
+ bits = np.zeros(w, dtype=np.int64)
282
+ for r in range(bh):
283
+ bits |= eq[r].astype(np.int64) << r
284
+ out += _rle(bits + 63)
285
+ out += b"\x1b\\"
286
+ return bytes(out)
287
+
288
+
289
+ def _render_bytes(path, fd):
290
+ """Return the terminal byte stream to display `path` (external cmd or built-in)."""
291
+ cmd = _cfg["imgcat"]
292
+ if cmd:
293
+ full = _fmt(_resolve_cmd(cmd, fd), path)
294
+ return subprocess.run(["sh", "-c", full], capture_output=True).stdout
295
+ img = _load_rgb(path)
296
+ tw, th = _target_size(fd, img.shape[1], img.shape[0])
297
+ img = _resize(img, tw, th)
298
+ palette, indices = _quantize(img)
299
+ return _sixel_bytes(palette, indices, img.shape[0], img.shape[1])
300
+
301
+
302
+ # ---- pane resolution --------------------------------------------------------
303
+
304
+ def _resolve_pane(target):
305
+ """Negative ints index the current window's panes Python-style (-1 = last)."""
306
+ try:
307
+ idx = int(target)
308
+ except (TypeError, ValueError):
309
+ return str(target) # named target, e.g. "Plots:0.0"
310
+ if idx >= 0:
311
+ return str(idx)
312
+ try:
313
+ out = subprocess.run([_cfg["tmux"], "list-panes", "-F", "#{pane_id}"],
314
+ capture_output=True, text=True, check=False)
315
+ ids = out.stdout.split()
316
+ if ids:
317
+ return ids[idx] # stable pane id (%N)
318
+ except OSError:
319
+ pass
320
+ return str(target)
321
+
322
+
323
+ # ---- talking to the viewer (or send-keys fallback) --------------------------
324
+
325
+ def _read_pid():
326
+ try:
327
+ with open(_pidfile) as f:
328
+ return int(f.read().strip())
329
+ except (OSError, ValueError):
330
+ return None
331
+
332
+
333
+ def _alive(pid):
334
+ if not pid:
335
+ return False
336
+ try:
337
+ os.kill(pid, 0)
338
+ except OSError:
339
+ return False
340
+ return True
341
+
342
+
343
+ def _signal_viewer():
344
+ pid = _read_pid()
345
+ if _alive(pid):
346
+ try:
347
+ os.kill(pid, signal.SIGUSR1)
348
+ return True
349
+ except OSError:
350
+ return False
351
+ return False
352
+
353
+
354
+ def _pane_render_cmd():
355
+ """Shell command that renders last.png in the plot pane (external or built-in)."""
356
+ cmd = _cfg["imgcat"]
357
+ if cmd:
358
+ fd, opened = -1, None
359
+ if "{width}" in cmd: # needs the pane's pixel width
360
+ tty = _pane_tty(_cfg["pane"])
361
+ if tty:
362
+ try:
363
+ opened = os.open(tty, os.O_RDONLY | os.O_NONBLOCK)
364
+ fd = opened
365
+ except OSError:
366
+ pass
367
+ resolved = _resolve_cmd(cmd, fd)
368
+ if opened is not None:
369
+ os.close(opened)
370
+ return _fmt(resolved, _last)
371
+ env = (
372
+ f"{_ENV}_IMGCAT='' " # force built-in in the subprocess
373
+ f"{_ENV}_CACHE={shlex.quote(_cache)} "
374
+ f"{_ENV}_SIZE={shlex.quote(str(_cfg['size']))}"
375
+ )
376
+ return (
377
+ f"{env} {shlex.quote(sys.executable)} "
378
+ f"{shlex.quote(os.path.abspath(__file__))} --render"
379
+ )
380
+
381
+
382
+ def _emit():
383
+ """send-keys fallback: tell the pane's shell to render last.png itself."""
384
+ cmd = _pane_render_cmd()
385
+ if _cfg["clear"]:
386
+ cmd = "clear && " + cmd
387
+ subprocess.run([_cfg["tmux"], "send-keys", "-t", str(_cfg["pane"]), cmd, "Enter"],
388
+ check=False)
389
+
390
+
391
+ def _pane_tty(pane):
392
+ """The tty device path of a tmux pane, e.g. /dev/ttys003 (None on failure)."""
393
+ try:
394
+ out = subprocess.run(
395
+ [_cfg["tmux"], "display-message", "-p", "-t", str(pane), "#{pane_tty}"],
396
+ capture_output=True, text=True, check=False)
397
+ except OSError:
398
+ return None
399
+ tty = out.stdout.strip()
400
+ return tty or None
401
+
402
+
403
+ def _write_inline(path):
404
+ """Render sixel without a viewer: to the target tmux pane's tty when in tmux,
405
+ otherwise to this terminal's own stdout."""
406
+ try:
407
+ if os.environ.get("TMUX") is not None:
408
+ tty = _pane_tty(_cfg["pane"])
409
+ if tty:
410
+ with open(tty, "wb", buffering=0) as out:
411
+ data = _render_bytes(path, out.fileno())
412
+ if _cfg["clear"]:
413
+ out.write(b"\x1b[H\x1b[2J")
414
+ out.write(data)
415
+ return
416
+ data = _render_bytes(path, _out_fd())
417
+ buf = sys.stdout.buffer
418
+ buf.write(data)
419
+ buf.write(b"\n")
420
+ buf.flush()
421
+ except Exception as exc:
422
+ print(f"[{__name__}] inline render failed: {exc}", file=sys.stderr)
423
+
424
+
425
+ def _publish(src):
426
+ tmp = _last + ".part"
427
+ try:
428
+ shutil.copyfile(src, tmp)
429
+ os.replace(tmp, _last)
430
+ except OSError:
431
+ pass
432
+
433
+
434
+ def _display_figure(fig):
435
+ path = os.path.join(_tmpdir, f"fig-{next(_counter):04d}.png")
436
+ kw = {"bbox_inches": "tight"}
437
+ if _cfg["dpi"]:
438
+ kw["dpi"] = int(_cfg["dpi"])
439
+ fig.savefig(path, **kw)
440
+ _recent.append(path)
441
+ while len(_recent) > _KEEP:
442
+ try:
443
+ os.remove(_recent.pop(0))
444
+ except OSError:
445
+ pass
446
+ _publish(path)
447
+ if _cfg["inline"]:
448
+ _write_inline(path)
449
+ elif not _signal_viewer():
450
+ _emit()
451
+
452
+
453
+ # ---- matplotlib backend API -------------------------------------------------
454
+
455
+ FigureCanvas = FigureCanvasAgg
456
+ FigureManager = backend_agg.FigureManagerBase
457
+
458
+
459
+ def new_figure_manager(num, *args, FigureClass=Figure, **kwargs):
460
+ return new_figure_manager_given_figure(num, FigureClass(*args, **kwargs))
461
+
462
+
463
+ def new_figure_manager_given_figure(num, figure):
464
+ return backend_agg.new_figure_manager_given_figure(num, figure)
465
+
466
+
467
+ def draw_if_interactive():
468
+ pass
469
+
470
+
471
+ def show(*args, **kwargs):
472
+ managers = Gcf.get_all_fig_managers()
473
+ if not managers:
474
+ return
475
+ for manager in managers:
476
+ _display_figure(manager.canvas.figure)
477
+ if _cfg["close"]:
478
+ Gcf.destroy_all()
479
+
480
+
481
+ def redraw():
482
+ if _cfg["inline"]:
483
+ if os.path.exists(_last):
484
+ _write_inline(_last)
485
+ elif not _signal_viewer() and os.path.exists(_last):
486
+ _emit()
487
+
488
+
489
+ # ---- the viewer (runs in the plot pane) -------------------------------------
490
+
491
+ def _apply_env():
492
+ """Load renderer settings from the environment (for the --view/--render subprocesses)."""
493
+ _cfg["imgcat"] = _env("IMGCAT", "") or None # empty -> built-in encoder
494
+ _cfg["size"] = _env("SIZE", _cfg["size"])
495
+
496
+
497
+ def view():
498
+ _apply_env()
499
+ clear = _env("CLEAR", "1" if _cfg["clear"] else "0") != "0"
500
+
501
+ def _draw(*_):
502
+ if not os.path.exists(_last):
503
+ return
504
+ try:
505
+ data = _render_bytes(_last, _out_fd())
506
+ except Exception:
507
+ return
508
+ buf = sys.stdout.buffer
509
+ if clear:
510
+ buf.write(b"\x1b[H\x1b[2J") # home + clear screen
511
+ buf.write(data)
512
+ buf.flush()
513
+
514
+ def _bye(*_):
515
+ try:
516
+ if _read_pid() == os.getpid():
517
+ os.remove(_pidfile)
518
+ except OSError:
519
+ pass
520
+ os._exit(0)
521
+
522
+ with open(_pidfile, "w") as f:
523
+ f.write(str(os.getpid()))
524
+ handlers = {"SIGUSR1": _draw, "SIGWINCH": _draw, # new figure / pane resize
525
+ "SIGTERM": _bye, "SIGINT": _bye, "SIGHUP": _bye}
526
+ for name, handler in handlers.items():
527
+ if hasattr(signal, name):
528
+ signal.signal(getattr(signal, name), handler)
529
+ _draw()
530
+ while True:
531
+ signal.pause()
532
+
533
+
534
+ # ---- setup / teardown -------------------------------------------------------
535
+
536
+ def _ensure_viewer():
537
+ if _alive(_read_pid()):
538
+ return
539
+ # Always pass IMGCAT (empty == built-in) so the viewer's renderer matches the
540
+ # backend's, regardless of any PLOTTY_IMGCAT inherited by the pane's shell.
541
+ parts = [
542
+ f"{_ENV}_IMGCAT={shlex.quote(_cfg['imgcat'] or '')}",
543
+ f"{_ENV}_CLEAR={'1' if _cfg['clear'] else '0'}",
544
+ f"{_ENV}_CACHE={shlex.quote(_cache)}",
545
+ f"{_ENV}_SIZE={shlex.quote(str(_cfg['size']))}",
546
+ ]
547
+ launch = (
548
+ " ".join(parts)
549
+ + f" {shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} --view"
550
+ )
551
+ subprocess.run([_cfg["tmux"], "send-keys", "-t", str(_cfg["pane"]), launch, "Enter"],
552
+ check=False)
553
+
554
+
555
+ _hook_cb = None
556
+
557
+
558
+ def hook():
559
+ global _hook_cb
560
+ if _hook_cb is not None:
561
+ return
562
+ try:
563
+ ip = get_ipython() # noqa: F821
564
+ except NameError:
565
+ ip = None
566
+ if ip is not None:
567
+ _hook_cb = lambda *a, **k: show()
568
+ ip.events.register("post_run_cell", _hook_cb)
569
+
570
+
571
+ def _tmux_version():
572
+ try:
573
+ out = subprocess.run([_cfg["tmux"], "-V"], capture_output=True, text=True,
574
+ check=False).stdout
575
+ except OSError:
576
+ return None
577
+ m = re.search(r"(\d+)\.(\d+)", out)
578
+ return (int(m.group(1)), int(m.group(2))) if m else None
579
+
580
+
581
+ def _tmux_features():
582
+ """The terminal features tmux has resolved for the current client, if any."""
583
+ for fmt in ("#{client_termfeatures}", "#{terminal-features}"):
584
+ try:
585
+ out = subprocess.run([_cfg["tmux"], "display-message", "-p", fmt],
586
+ capture_output=True, text=True, check=False).stdout.strip()
587
+ except OSError:
588
+ return None
589
+ if out:
590
+ return out
591
+ return ""
592
+
593
+
594
+ def _health_check(verbose):
595
+ """Warn about likely sixel-display problems up front (best effort)."""
596
+ if not verbose:
597
+ return
598
+ name = __name__
599
+ intmux = os.environ.get("TMUX") is not None
600
+ if _cfg["inline"]:
601
+ where = "the target tmux pane" if intmux else "this terminal"
602
+ print(f"[{name}] inline mode: piping sixel to {where} "
603
+ f"(requires a sixel-capable terminal)", file=sys.stderr)
604
+ elif not intmux:
605
+ print(f"[{name}] inline mode is off but you are not in tmux; pane routing "
606
+ f"will not work — pass inline=True to enable()", file=sys.stderr)
607
+ if intmux: # both modes lean on tmux's sixel
608
+ ver = _tmux_version()
609
+ if ver is not None and ver < (3, 4):
610
+ print(f"[{name}] tmux {ver[0]}.{ver[1]} is older than 3.4 and may not "
611
+ f"render sixel; upgrade tmux for native sixel support",
612
+ file=sys.stderr)
613
+ feats = _tmux_features()
614
+ if feats is not None and "sixel" not in feats:
615
+ print(f"[{name}] tmux does not report a 'sixel' terminal feature; if "
616
+ f"plots don't appear, run: tmux set -as terminal-features "
617
+ f"',*:sixel' (and make sure your terminal supports sixel)",
618
+ file=sys.stderr)
619
+
620
+
621
+ def _resolve_inline(inline):
622
+ """inline: None -> auto (True when not in tmux); else honour the bool.
623
+
624
+ `PLOTTY_INLINE` (1/0) overrides auto-detection but not an explicit argument.
625
+ """
626
+ if inline is not None:
627
+ return bool(inline)
628
+ env_inline = _env("INLINE", None)
629
+ if env_inline is not None:
630
+ return env_inline != "0"
631
+ return os.environ.get("TMUX") is None
632
+
633
+
634
+ def _resolve_imgcat(imgcat, verbose):
635
+ """Resolve the renderer command, where None means the built-in encoder.
636
+
637
+ imgcat=None consults PLOTTY_IMGCAT then auto-detects; "" / "builtin" / False
638
+ force the built-in encoder; any other string is used as the command.
639
+ """
640
+ if imgcat is None:
641
+ imgcat = _env("IMGCAT", None)
642
+ if imgcat in ("", "builtin", False):
643
+ return None
644
+ if imgcat is None: # auto-detect an external renderer
645
+ imgcat = _auto_imgcat()
646
+ if imgcat is None and verbose:
647
+ print(f"[{__name__}] no external renderer on PATH; using built-in "
648
+ f"sixel encoder (install chafa for higher-quality output)",
649
+ file=sys.stderr)
650
+ if imgcat and verbose and not _is_sixel(imgcat):
651
+ print(f"[{__name__}] {shlex.split(imgcat)[0]} is not sixel, so image "
652
+ f"display may not work over ssh", file=sys.stderr)
653
+ return imgcat
654
+
655
+
656
+ def enable(target_pane=-1, imgcat=None, clear=True, tmux="tmux", dpi=None,
657
+ close=True, size=None, inline=None, viewer=True, verbose=1):
658
+ """Activate plotty: detect a renderer, point at a pane, start the viewer.
659
+
660
+ inline=None (default) auto-selects: inline mode when not in tmux, viewer-pane
661
+ mode when in tmux. In inline mode the backend renders sixel itself (no viewer
662
+ process) and writes it to the target pane's tty when in tmux, or to this
663
+ terminal's stdout when not. inline=True forces inline even inside tmux;
664
+ inline=False forces viewer-pane mode. `PLOTTY_INLINE=1/0` sets the default.
665
+
666
+ imgcat=None (default) auto-detects an external renderer (chafa/img2sixel/
667
+ magick), falling back to the built-in encoder if none is found. Pass
668
+ imgcat="builtin" (or "" / False) to force the built-in encoder even when an
669
+ external one is installed; pass a command string to use it explicitly.
670
+ `PLOTTY_IMGCAT` sets the default (`PLOTTY_IMGCAT=builtin` forces built-in).
671
+ This applies to both viewer and inline modes.
672
+
673
+ size (display width in cells, default 60) and dpi (matplotlib savefig DPI;
674
+ None = matplotlib's own default) control display size and source-image
675
+ resolution respectively. Both fall back to `PLOTTY_SIZE` / `PLOTTY_DPI` when
676
+ the argument is None. Raise dpi when displaying at a large size so the source
677
+ PNG has enough pixels to stay sharp (else the renderer upscales it).
678
+ """
679
+ _cfg["tmux"] = tmux
680
+ _cfg["clear"] = clear
681
+ _cfg["dpi"] = _env("DPI", None) if dpi is None else dpi
682
+ _cfg["close"] = close
683
+ _cfg["size"] = _env("SIZE", 60) if size is None else size
684
+
685
+ _cfg["imgcat"] = _resolve_imgcat(imgcat, verbose)
686
+
687
+ matplotlib.use(f"module://{__name__}")
688
+ matplotlib.interactive(True)
689
+
690
+ _cfg["inline"] = _resolve_inline(inline)
691
+ _health_check(verbose)
692
+ if _cfg["inline"]:
693
+ if os.environ.get("TMUX") is not None:
694
+ _cfg["pane"] = _resolve_pane(target_pane) # pipe sixel to this pane's tty
695
+ else:
696
+ _cfg["pane"] = _resolve_pane(target_pane)
697
+ if viewer:
698
+ _ensure_viewer()
699
+ hook()
700
+
701
+
702
+ def disable():
703
+ """Stop the viewer, unhook auto-display, and quiet matplotlib output."""
704
+ pid = _read_pid()
705
+ if _alive(pid):
706
+ try:
707
+ os.kill(pid, signal.SIGTERM)
708
+ except OSError:
709
+ pass
710
+ global _hook_cb
711
+ if _hook_cb is not None:
712
+ try:
713
+ get_ipython().events.unregister("post_run_cell", _hook_cb) # noqa: F821
714
+ except Exception:
715
+ pass
716
+ _hook_cb = None
717
+ try:
718
+ matplotlib.use("agg")
719
+ except Exception:
720
+ pass
721
+
722
+
723
+ if __name__ == "__main__":
724
+ if "--view" in sys.argv:
725
+ view()
726
+ elif "--render" in sys.argv:
727
+ # render last.png to this pane's stdout (send-keys fallback)
728
+ _apply_env()
729
+ if os.path.exists(_last):
730
+ sys.stdout.buffer.write(_render_bytes(_last, _out_fd()))
731
+ sys.stdout.buffer.flush()