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.
- plotty-1.0.0.dist-info/METADATA +268 -0
- plotty-1.0.0.dist-info/RECORD +7 -0
- plotty-1.0.0.dist-info/WHEEL +5 -0
- plotty-1.0.0.dist-info/entry_points.txt +2 -0
- plotty-1.0.0.dist-info/licenses/LICENSE +21 -0
- plotty-1.0.0.dist-info/top_level.txt +1 -0
- plotty.py +731 -0
|
@@ -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
|
+
[](https://github.com/xuesoso/plotty/actions/workflows/ci.yml)
|
|
28
|
+
[](https://github.com/xuesoso/plotty/actions/workflows/ci.yml)
|
|
29
|
+
[](pyproject.toml)
|
|
30
|
+
[](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,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()
|