plotui 0.5.1__cp39-abi3-win_amd64.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.
- plotui/__init__.py +47 -0
- plotui/_bin/plotui.exe +0 -0
- plotui/_cli.py +33 -0
- plotui/_plotui.pyd +0 -0
- plotui/textual.py +772 -0
- plotui-0.5.1.dist-info/METADATA +288 -0
- plotui-0.5.1.dist-info/RECORD +11 -0
- plotui-0.5.1.dist-info/WHEEL +4 -0
- plotui-0.5.1.dist-info/entry_points.txt +2 -0
- plotui-0.5.1.dist-info/licenses/LICENSE +21 -0
- plotui-0.5.1.dist-info/sboms/plotui-py.cyclonedx.json +1390 -0
plotui/textual.py
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
"""Textual integration for plotui.
|
|
2
|
+
|
|
3
|
+
``PlotWidget`` embeds an interactive plot inside a Textual app. Textual owns the
|
|
4
|
+
loop and input; the widget forwards mouse/key events to the plot's camera and
|
|
5
|
+
asks the Rust core for a fresh frame on each refresh.
|
|
6
|
+
|
|
7
|
+
Rendering picks the best path for the terminal (see `detect_render_mode`):
|
|
8
|
+
|
|
9
|
+
- **"placeholder"** (Kitty, Ghostty): full-resolution pixel images composited
|
|
10
|
+
via Kitty's Unicode placeholders — flicker-free, and text overlays splice
|
|
11
|
+
cleanly into the image.
|
|
12
|
+
- **"direct"** (iTerm2 ≥ 3.5, WezTerm, Konsole): these speak the Kitty
|
|
13
|
+
graphics protocol but not Unicode placeholders, so the image is drawn
|
|
14
|
+
directly at the widget's origin instead — still full resolution.
|
|
15
|
+
|
|
16
|
+
plotui only draws real pixels: terminals without Kitty graphics support get
|
|
17
|
+
a message naming supported terminals rather than a degraded plot.
|
|
18
|
+
|
|
19
|
+
Inside tmux, direct-mode image escapes are wrapped for tmux passthrough (see
|
|
20
|
+
`tmux_wrap`), so the picture reaches the outer terminal — e.g. a browser
|
|
21
|
+
xterm.js with the image addon. This needs ``set -g allow-passthrough on`` and
|
|
22
|
+
``PLOTUI_RENDER=direct`` (xterm.js's Kitty support is direct-placement only).
|
|
23
|
+
|
|
24
|
+
Set the ``PLOTUI_RENDER`` environment variable (or the widget's
|
|
25
|
+
``render_mode`` parameter) to override detection.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
import os
|
|
32
|
+
|
|
33
|
+
from rich.cells import cell_len
|
|
34
|
+
from rich.color import Color
|
|
35
|
+
from rich.segment import Segment
|
|
36
|
+
from rich.style import Style
|
|
37
|
+
from textual import events
|
|
38
|
+
from textual.message import Message
|
|
39
|
+
from textual.strip import Strip
|
|
40
|
+
from textual.widget import Widget
|
|
41
|
+
|
|
42
|
+
from ._plotui import Plot
|
|
43
|
+
from ._plotui import detect_cell_px as _detect_cell_px
|
|
44
|
+
from ._plotui import detect_render_mode as _detect_render_mode
|
|
45
|
+
from ._plotui import tmux_wrap as _tmux_wrap
|
|
46
|
+
|
|
47
|
+
# Fallback cell size in device pixels, used only when the terminal doesn't
|
|
48
|
+
# report its own (see `detect_cell_px`). The image is scaled to the cell grid
|
|
49
|
+
# by the terminal, so a too-small guess renders below native resolution and
|
|
50
|
+
# gets upscaled — soft edges. Detection avoids that.
|
|
51
|
+
_CELL_W, _CELL_H = 12, 24
|
|
52
|
+
|
|
53
|
+
# Above this node count, 3D plots drop to half resolution *while interacting*
|
|
54
|
+
# (dragging or auto-rotating) and snap back to full resolution when still.
|
|
55
|
+
_LARGE_NODE_COUNT = 400
|
|
56
|
+
|
|
57
|
+
# Radians of yaw per auto-rotate tick, matching plotui_term's constant for
|
|
58
|
+
# the Rust terminal frontends. `Plot.spin` owns the *direction*; this is
|
|
59
|
+
# only how fast.
|
|
60
|
+
AUTO_ROTATE_STEP = 0.02
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tmux_wrap(escape: str) -> str:
|
|
64
|
+
"""Wrap a terminal escape for tmux passthrough when running inside tmux.
|
|
65
|
+
|
|
66
|
+
tmux intercepts control sequences it doesn't model (like the Kitty
|
|
67
|
+
graphics APC), so an image drawn by direct placement never reaches the
|
|
68
|
+
outer terminal. tmux's passthrough — ``\\ePtmux;<payload>\\e\\`` with every
|
|
69
|
+
ESC in the payload doubled — hands the raw bytes to the outer terminal.
|
|
70
|
+
Requires ``set -g allow-passthrough on`` in tmux. A no-op outside tmux
|
|
71
|
+
(``$TMUX`` unset), so normal terminals are unaffected."""
|
|
72
|
+
return _tmux_wrap(escape)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_cell_px(fallback: tuple[int, int] = (_CELL_W, _CELL_H)) -> tuple[int, int]:
|
|
76
|
+
"""The terminal's pixel-per-cell size, queried via the TIOCGWINSZ ioctl
|
|
77
|
+
(``ws_xpixel``/``ws_ypixel``). Kitty, Ghostty, iTerm2, and WezTerm all
|
|
78
|
+
report it — and report *device* pixels, so this yields the true retina
|
|
79
|
+
resolution. Returns `fallback` when the terminal reports no pixel size
|
|
80
|
+
(or on platforms without termios, e.g. Windows)."""
|
|
81
|
+
return _detect_cell_px(fallback)
|
|
82
|
+
|
|
83
|
+
# An overlay span: (row, col, text, style) — text drawn over the plot in
|
|
84
|
+
# terminal cells (labels, badges). See `PlotWidget.set_overlay`.
|
|
85
|
+
OverlaySpan = tuple[int, int, str, Style | None]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
RENDER_MODES = ("placeholder", "direct")
|
|
89
|
+
|
|
90
|
+
# What the widget shows instead of a degraded plot when the terminal has no
|
|
91
|
+
# Kitty graphics support (centered in the plot area). Change these strings
|
|
92
|
+
# only in lockstep with UNSUPPORTED_MESSAGE in crates/plotui-term/src/policy.rs
|
|
93
|
+
# — the Rust frontends center the same notice.
|
|
94
|
+
_UNSUPPORTED_MESSAGE: tuple[tuple[str, Style | None], ...] = (
|
|
95
|
+
("Plotting requires a terminal that supports the Kitty graphics protocol.", Style(bold=True)),
|
|
96
|
+
("", None),
|
|
97
|
+
("Supported terminals include Kitty, Ghostty, iTerm2 (3.5+), WezTerm, and Konsole.", None),
|
|
98
|
+
("If yours does support it, force a path with PLOTUI_RENDER=placeholder|direct.", Style(dim=True)),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def detect_render_mode(env: dict[str, str] | None = None) -> str:
|
|
103
|
+
"""Pick the best render path for this terminal.
|
|
104
|
+
|
|
105
|
+
- ``"placeholder"``: Kitty graphics via Unicode placeholders (`U=1`) —
|
|
106
|
+
Kitty and Ghostty. Flicker-free and splices with text overlays.
|
|
107
|
+
- ``"direct"``: Kitty graphics drawn at the widget origin — for terminals
|
|
108
|
+
that speak the protocol but not placeholders: iTerm2 ≥ 3.5, WezTerm,
|
|
109
|
+
Konsole. Still full resolution.
|
|
110
|
+
- ``"unsupported"``: no Kitty graphics — the widget shows a message
|
|
111
|
+
naming supported terminals instead of degrading the plot.
|
|
112
|
+
|
|
113
|
+
``PLOTUI_RENDER`` overrides detection with ``placeholder`` or ``direct``
|
|
114
|
+
("kitty" is accepted as an alias for "placeholder").
|
|
115
|
+
"""
|
|
116
|
+
return _detect_render_mode(None if env is None else dict(env))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PlotWidget(Widget, can_focus=True):
|
|
120
|
+
"""A Textual widget hosting an interactive plotui plot."""
|
|
121
|
+
|
|
122
|
+
# A plot isn't text — dragging over it rotates, it doesn't select text.
|
|
123
|
+
ALLOW_SELECT = False
|
|
124
|
+
|
|
125
|
+
DEFAULT_CSS = """
|
|
126
|
+
PlotWidget { width: 1fr; height: 1fr; }
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
class NodePicked(Message):
|
|
130
|
+
"""Posted when the user clicks (without dragging).
|
|
131
|
+
|
|
132
|
+
`index` is the flat node index, or `None` if empty space was clicked.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, plot_widget: "PlotWidget", index: int | None) -> None:
|
|
136
|
+
super().__init__()
|
|
137
|
+
self.plot_widget = plot_widget
|
|
138
|
+
self.index = index
|
|
139
|
+
|
|
140
|
+
class ElementHovered(Message):
|
|
141
|
+
"""Posted when the hovered element changes (`pickable=True` only).
|
|
142
|
+
|
|
143
|
+
`element` is `("node", index)`, `("edge", index)`, or `None`.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, plot_widget: "PlotWidget", element: tuple[str, int] | None) -> None:
|
|
147
|
+
super().__init__()
|
|
148
|
+
self.plot_widget = plot_widget
|
|
149
|
+
self.element = element
|
|
150
|
+
|
|
151
|
+
class ElementPicked(Message):
|
|
152
|
+
"""Posted on click when `pickable=True`.
|
|
153
|
+
|
|
154
|
+
`element` is `("node", index)`, `("edge", index)`, or `None` if empty
|
|
155
|
+
space was clicked.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, plot_widget: "PlotWidget", element: tuple[str, int] | None) -> None:
|
|
159
|
+
super().__init__()
|
|
160
|
+
self.plot_widget = plot_widget
|
|
161
|
+
self.element = element
|
|
162
|
+
|
|
163
|
+
class RangeChanged(Message):
|
|
164
|
+
"""Posted when the x window changes through a finished gesture (a
|
|
165
|
+
released range-slider drag, a scroll zoom, or an ``[``/``]`` key).
|
|
166
|
+
|
|
167
|
+
`window` is the new ``(lo, hi)`` in data coordinates, or `None` for
|
|
168
|
+
the full extent.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self, plot_widget: "PlotWidget", window: tuple[float, float] | None
|
|
173
|
+
) -> None:
|
|
174
|
+
super().__init__()
|
|
175
|
+
self.plot_widget = plot_widget
|
|
176
|
+
self.window = window
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
plot: Plot,
|
|
181
|
+
*,
|
|
182
|
+
auto_rotate: bool = False,
|
|
183
|
+
cell_px: tuple[int, int] | None = None,
|
|
184
|
+
pickable: bool = False,
|
|
185
|
+
crosshair: bool = True,
|
|
186
|
+
range_slider: bool = False,
|
|
187
|
+
render_mode: str = "auto",
|
|
188
|
+
interactive_scale: float = 0.5,
|
|
189
|
+
**kwargs,
|
|
190
|
+
):
|
|
191
|
+
"""``pickable=True`` turns on interactive picking: moving the mouse
|
|
192
|
+
over a node or a graph edge lights it up white (it can be clicked),
|
|
193
|
+
and clicking posts :class:`ElementPicked` with what was hit. Off by
|
|
194
|
+
default so plots without click semantics pay no per-mouse-move cost.
|
|
195
|
+
|
|
196
|
+
``crosshair`` (default on) gives 2D plots a hover crosshair: a
|
|
197
|
+
vertical guide snapped to the nearest sample x, a marker per series,
|
|
198
|
+
and a value readout. 2D renders are cheap enough to repaint per
|
|
199
|
+
mouse-move. 3D plots are unaffected.
|
|
200
|
+
|
|
201
|
+
``range_slider`` gives 2D plots a Plotly-style range slider: a
|
|
202
|
+
full-extent overview strip under the plot whose window (drag its
|
|
203
|
+
handles or body, click the track, scroll over the plot, ``[``/``]``)
|
|
204
|
+
sets the plot's x view; gestures post :class:`RangeChanged`. It also
|
|
205
|
+
works when the wrapped plot already has ``range_slider`` enabled.
|
|
206
|
+
|
|
207
|
+
``render_mode`` is ``"auto"`` (detect, honoring ``PLOTUI_RENDER``) or
|
|
208
|
+
``"placeholder"`` / ``"direct"`` to force a path — see
|
|
209
|
+
:func:`detect_render_mode`.
|
|
210
|
+
|
|
211
|
+
``cell_px`` sets the device pixels per terminal cell (rendering
|
|
212
|
+
resolution). Default ``None`` detects the terminal's true cell size so
|
|
213
|
+
plots render at native resolution — see :func:`detect_cell_px`.
|
|
214
|
+
|
|
215
|
+
``interactive_scale`` is the resolution multiplier used for large 3D
|
|
216
|
+
plots *while interacting* (dragging or auto-rotating); ``1.0`` disables
|
|
217
|
+
it. Full resolution is restored the moment interaction stops.
|
|
218
|
+
"""
|
|
219
|
+
super().__init__(**kwargs)
|
|
220
|
+
self._plot = plot
|
|
221
|
+
if range_slider:
|
|
222
|
+
plot.set_range_slider(True)
|
|
223
|
+
self._dragging = False
|
|
224
|
+
self._moved = False
|
|
225
|
+
self._last_pos = (0, 0)
|
|
226
|
+
# The strip part grabbed by the active drag ("left"/"right"/"window"),
|
|
227
|
+
# if the drag started on the range slider.
|
|
228
|
+
self._range_drag: str | None = None
|
|
229
|
+
self._auto = auto_rotate
|
|
230
|
+
self._cell_w, self._cell_h = cell_px if cell_px is not None else detect_cell_px()
|
|
231
|
+
self._pickable = pickable
|
|
232
|
+
self._crosshair = crosshair
|
|
233
|
+
self._interactive_scale = min(1.0, max(0.05, interactive_scale))
|
|
234
|
+
# Vertex count, not node count: line vertices and surface grids load
|
|
235
|
+
# the rasterizer just as much as pickable nodes do.
|
|
236
|
+
self._large = plot.vertex_count() >= _LARGE_NODE_COUNT
|
|
237
|
+
# Direct mode deletes the prior image before each frame to stop iTerm2
|
|
238
|
+
# stacking placements. Terminals whose Kitty decoder replaces a same-id
|
|
239
|
+
# image (xterm.js addon-image) flicker from that delete; PLOTUI_KITTY_
|
|
240
|
+
# REPLACE=1 skips it. See render_kitty(replace=...).
|
|
241
|
+
self._kitty_replace = os.environ.get("PLOTUI_KITTY_REPLACE", "").strip() in ("1", "true")
|
|
242
|
+
self._hovered: tuple[str, int] | None = None
|
|
243
|
+
if render_mode != "auto":
|
|
244
|
+
if render_mode not in (*RENDER_MODES, "unsupported"):
|
|
245
|
+
raise ValueError(f"render_mode must be 'auto' or one of {RENDER_MODES}")
|
|
246
|
+
self._mode = render_mode
|
|
247
|
+
else:
|
|
248
|
+
self._mode = detect_render_mode()
|
|
249
|
+
# Frame cache, keyed on (w, h, version, mode) so we rasterize once per
|
|
250
|
+
# change rather than once per rendered line.
|
|
251
|
+
self._version = 0
|
|
252
|
+
self._key = None
|
|
253
|
+
self._transmit = ""
|
|
254
|
+
self._cells: list[list[str]] | None = None
|
|
255
|
+
self._style: Style | None = None
|
|
256
|
+
# Text overlay, row -> non-overlapping spans sorted by column. Kept
|
|
257
|
+
# outside the frame cache: changing it never re-rasterizes the image.
|
|
258
|
+
self._overlay: dict[int, list[tuple[int, str, Style | None]]] = {}
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def plot(self) -> Plot:
|
|
262
|
+
"""The wrapped :class:`Plot`. Mutate it freely — camera calls, or
|
|
263
|
+
``extend``/``set_visible`` by trace handle — then call
|
|
264
|
+
:meth:`invalidate` (or use the widget-level :meth:`extend` /
|
|
265
|
+
:meth:`set_visible`, which do both)."""
|
|
266
|
+
return self._plot
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def dragging(self) -> bool:
|
|
270
|
+
"""True while the user is actively dragging (rotating/panning) — a
|
|
271
|
+
hook for hosts that want to defer expensive work mid-gesture."""
|
|
272
|
+
return self._dragging and self._moved
|
|
273
|
+
|
|
274
|
+
def _active_scale(self) -> float:
|
|
275
|
+
"""Resolution multiplier for the next frame: reduced only for large 3D
|
|
276
|
+
plots while interacting (an active drag, or continuous auto-rotate),
|
|
277
|
+
else 1.0 — so a still plot is always at full resolution."""
|
|
278
|
+
if self._interactive_scale >= 1.0 or not self._large or not self._plot.is_3d():
|
|
279
|
+
return 1.0
|
|
280
|
+
return self._interactive_scale if (self.dragging or self._auto) else 1.0
|
|
281
|
+
|
|
282
|
+
def on_mount(self) -> None:
|
|
283
|
+
if self._auto:
|
|
284
|
+
self.set_interval(1 / 30, self._tick)
|
|
285
|
+
|
|
286
|
+
def on_unmount(self) -> None:
|
|
287
|
+
# Delete our image placements so nothing outlives the app: without
|
|
288
|
+
# this, terminals that keep Kitty-graphics placements around (iTerm2
|
|
289
|
+
# in particular) leave the last frame painted over the shell.
|
|
290
|
+
if self._mode in ("placeholder", "direct"):
|
|
291
|
+
driver = getattr(self.app, "_driver", None)
|
|
292
|
+
if driver is not None:
|
|
293
|
+
try:
|
|
294
|
+
driver.write(tmux_wrap(Plot.kitty_cleanup()))
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
def _tick(self) -> None:
|
|
299
|
+
# Not routed through apply_rotate: that hook is for input paths
|
|
300
|
+
# (drag, scroll, keys), and an idle spin is not one. `spin` also
|
|
301
|
+
# owns the direction — it turns the way a rightward drag pushes the
|
|
302
|
+
# object, so letting go of a grabbed plot does not send it back the
|
|
303
|
+
# way it came.
|
|
304
|
+
self._plot.spin(AUTO_ROTATE_STEP)
|
|
305
|
+
self.invalidate()
|
|
306
|
+
|
|
307
|
+
# ---- overridable interaction primitives ----
|
|
308
|
+
# Every built-in input path (mouse drag, scroll, keys) routes through
|
|
309
|
+
# these, so a subclass can hook camera changes and clicks WITHOUT
|
|
310
|
+
# overriding Textual event handlers — Textual dispatches on_* handlers to
|
|
311
|
+
# every class in the MRO, so an override would run in addition to this
|
|
312
|
+
# class's handler, not instead of it.
|
|
313
|
+
|
|
314
|
+
def _apply_mapped_drag(
|
|
315
|
+
self,
|
|
316
|
+
dx: float,
|
|
317
|
+
dy: float,
|
|
318
|
+
shift: bool,
|
|
319
|
+
rotate: float,
|
|
320
|
+
pan_x: float,
|
|
321
|
+
pan_y: float,
|
|
322
|
+
zoom: float,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""One drag gesture, decomposed through the plot's input map into the
|
|
325
|
+
camera moves it maps to — and issued as `apply_*` calls.
|
|
326
|
+
|
|
327
|
+
The breakdown happens here rather than in `plot.apply_drag` so a drag
|
|
328
|
+
still lands on the hooks below, which a subclass may have overridden.
|
|
329
|
+
Moving the camera in the core directly would go behind that
|
|
330
|
+
subclass's back: a view that locks rotation (a flat tree that pans
|
|
331
|
+
instead of tilting) would tilt anyway, and a host that repaints an
|
|
332
|
+
overlay on camera changes would keep drawing a stale one. Scales and
|
|
333
|
+
signs mirror `Plot::apply_drag` exactly; at most one call per camera
|
|
334
|
+
kind, so a diagonal drag is a single rotate.
|
|
335
|
+
"""
|
|
336
|
+
controls = self._plot.input_map()
|
|
337
|
+
d_yaw = d_pitch = pan_dx = pan_dy = 0.0
|
|
338
|
+
factor = 1.0
|
|
339
|
+
for control, d in zip(controls[2:] if shift else controls[:2], (dx, dy)):
|
|
340
|
+
if control.startswith("-"):
|
|
341
|
+
control, d = control[1:], -d
|
|
342
|
+
if control == "yaw":
|
|
343
|
+
d_yaw -= d * rotate
|
|
344
|
+
elif control == "pitch":
|
|
345
|
+
d_pitch -= d * rotate
|
|
346
|
+
elif control == "pan_x":
|
|
347
|
+
pan_dx += d * pan_x
|
|
348
|
+
elif control == "pan_y":
|
|
349
|
+
pan_dy += d * pan_y
|
|
350
|
+
elif control == "zoom":
|
|
351
|
+
factor *= math.exp(-d * zoom)
|
|
352
|
+
if d_yaw or d_pitch:
|
|
353
|
+
self.apply_rotate(d_yaw, d_pitch)
|
|
354
|
+
if pan_dx or pan_dy:
|
|
355
|
+
self.apply_pan(pan_dx, pan_dy)
|
|
356
|
+
if factor != 1.0:
|
|
357
|
+
self.apply_zoom(factor)
|
|
358
|
+
|
|
359
|
+
def apply_rotate(self, d_yaw: float, d_pitch: float) -> None:
|
|
360
|
+
self._plot.rotate(d_yaw, d_pitch)
|
|
361
|
+
self.invalidate()
|
|
362
|
+
|
|
363
|
+
def apply_pan(self, dx: float, dy: float) -> None:
|
|
364
|
+
self._plot.pan(dx, dy)
|
|
365
|
+
self.invalidate()
|
|
366
|
+
|
|
367
|
+
def apply_zoom(self, factor: float) -> None:
|
|
368
|
+
self._plot.zoom_by(factor)
|
|
369
|
+
self.invalidate()
|
|
370
|
+
|
|
371
|
+
def apply_reset(self) -> None:
|
|
372
|
+
self._plot.reset()
|
|
373
|
+
if self._plot.set_x_window(None):
|
|
374
|
+
self.post_message(self.RangeChanged(self, None))
|
|
375
|
+
self.invalidate()
|
|
376
|
+
|
|
377
|
+
def apply_x_window(self, window: tuple[float, float] | None) -> None:
|
|
378
|
+
"""Set (or clear) the 2D x view programmatically; repaints on change.
|
|
379
|
+
Interactive changes post :class:`RangeChanged` instead."""
|
|
380
|
+
if self._plot.set_x_window(window):
|
|
381
|
+
self.invalidate()
|
|
382
|
+
|
|
383
|
+
def on_click_at(self, event: events.MouseUp) -> None:
|
|
384
|
+
"""Click semantics (a press-and-release without movement). The default
|
|
385
|
+
picks and selects; subclasses override for their own click behavior."""
|
|
386
|
+
if self._pickable:
|
|
387
|
+
element = self._pick_at(event.x, event.y)
|
|
388
|
+
self._plot.set_selected(element)
|
|
389
|
+
self.invalidate()
|
|
390
|
+
self.post_message(self.ElementPicked(self, element))
|
|
391
|
+
# Keep the node-only message for handlers that predate edges.
|
|
392
|
+
if element is None or element[0] == "node":
|
|
393
|
+
self.post_message(self.NodePicked(self, element[1] if element else None))
|
|
394
|
+
return
|
|
395
|
+
px_w, px_h, px, py, radius = self._pixel_geometry(event.x, event.y)
|
|
396
|
+
idx = self._plot.pick_px(px_w, px_h, px, py, radius)
|
|
397
|
+
self._plot.set_selected(idx)
|
|
398
|
+
self.invalidate()
|
|
399
|
+
self.post_message(self.NodePicked(self, idx))
|
|
400
|
+
|
|
401
|
+
def invalidate(self) -> None:
|
|
402
|
+
"""Mark the view dirty and repaint (call after mutating the plot)."""
|
|
403
|
+
# Streamed data can grow a plot past the reduced-resolution threshold
|
|
404
|
+
# long after mount; vertex_count is O(traces), so re-checking on every
|
|
405
|
+
# invalidation is safe even mid-drag.
|
|
406
|
+
self._large = self._plot.vertex_count() >= _LARGE_NODE_COUNT
|
|
407
|
+
self._version += 1
|
|
408
|
+
self.refresh()
|
|
409
|
+
|
|
410
|
+
def extend(self, handle: int, xs, ys, zs=None) -> None:
|
|
411
|
+
"""Append points to a trace by handle (see :meth:`Plot.extend`) and
|
|
412
|
+
repaint. Multiple extends between frames coalesce into one repaint."""
|
|
413
|
+
if zs is None:
|
|
414
|
+
self._plot.extend(handle, xs, ys)
|
|
415
|
+
else:
|
|
416
|
+
self._plot.extend(handle, xs, ys, zs)
|
|
417
|
+
self.invalidate()
|
|
418
|
+
|
|
419
|
+
def set_visible(self, handle: int, visible: bool) -> bool:
|
|
420
|
+
"""Show or hide a trace by handle; repaints only when the state
|
|
421
|
+
actually changed. Returns True when it did."""
|
|
422
|
+
changed = self._plot.set_visible(handle, visible)
|
|
423
|
+
if changed:
|
|
424
|
+
self.invalidate()
|
|
425
|
+
return changed
|
|
426
|
+
|
|
427
|
+
def set_graph_positions(self, handle: int, xs, ys, zs) -> None:
|
|
428
|
+
"""Move every node of a graph trace at once and repaint — the
|
|
429
|
+
per-frame call of a force-directed layout (see
|
|
430
|
+
:meth:`Plot.set_graph_positions`, pair with ``ForceLayout``)."""
|
|
431
|
+
self._plot.set_graph_positions(handle, xs, ys, zs)
|
|
432
|
+
self.invalidate()
|
|
433
|
+
|
|
434
|
+
def set_graph_routes(self, handle: int, routes) -> None:
|
|
435
|
+
"""Replace a 2D graph's edge waypoints and repaint — the second half
|
|
436
|
+
of a relayout, after :meth:`set_graph_positions` has moved the nodes
|
|
437
|
+
(see :meth:`Plot.set_graph_routes`, pair with ``LayeredLayout``)."""
|
|
438
|
+
self._plot.set_graph_routes(handle, [list(r) for r in routes])
|
|
439
|
+
self.invalidate()
|
|
440
|
+
|
|
441
|
+
def set_graph_colors(self, handle: int, node_colors, edge_colors=None) -> None:
|
|
442
|
+
"""Recolor a graph trace in place and repaint — dim everything,
|
|
443
|
+
brighten a hovered dependency path, restore (see
|
|
444
|
+
:meth:`Plot.set_graph_colors`)."""
|
|
445
|
+
self._plot.set_graph_colors(handle, node_colors, edge_colors)
|
|
446
|
+
self.invalidate()
|
|
447
|
+
|
|
448
|
+
def extend_graph(
|
|
449
|
+
self, handle: int, xs, ys, zs, node_colors=None, edges=(), labels=None
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Append nodes and edges to a graph trace and repaint (see
|
|
452
|
+
:meth:`Plot.extend_graph`, pair with ``ForceLayout.add_node``).
|
|
453
|
+
``labels`` names the new boxes of a 2D graph; a 3D one ignores it."""
|
|
454
|
+
self._plot.extend_graph(
|
|
455
|
+
handle,
|
|
456
|
+
xs,
|
|
457
|
+
ys,
|
|
458
|
+
zs,
|
|
459
|
+
node_colors=node_colors,
|
|
460
|
+
edges=list(edges),
|
|
461
|
+
labels=labels,
|
|
462
|
+
)
|
|
463
|
+
self.invalidate()
|
|
464
|
+
|
|
465
|
+
def set_overlay(self, spans: list[OverlaySpan]) -> None:
|
|
466
|
+
"""Draw text over the plot: each span is `(row, col, text, style)` in
|
|
467
|
+
widget cells. Spans replace the image at the cells they cover (labels
|
|
468
|
+
sit on the terminal background). Overlapping or off-widget spans are
|
|
469
|
+
clipped/dropped. Repaints without re-rasterizing the image."""
|
|
470
|
+
w, h = self.size.width, self.size.height
|
|
471
|
+
overlay: dict[int, list[tuple[int, str, Style | None]]] = {}
|
|
472
|
+
for row, col, text, style in sorted(spans, key=lambda s: (s[0], s[1])):
|
|
473
|
+
if row < 0 or row >= h or col < 0 or not text:
|
|
474
|
+
continue
|
|
475
|
+
text = text[: max(0, w - col)]
|
|
476
|
+
if not text:
|
|
477
|
+
continue
|
|
478
|
+
row_spans = overlay.setdefault(row, [])
|
|
479
|
+
if row_spans:
|
|
480
|
+
prev_col, prev_text, _ = row_spans[-1]
|
|
481
|
+
if col < prev_col + cell_len(prev_text):
|
|
482
|
+
continue # overlaps the previous span — first one wins
|
|
483
|
+
row_spans.append((col, text, style))
|
|
484
|
+
self._overlay = overlay
|
|
485
|
+
self.refresh()
|
|
486
|
+
|
|
487
|
+
# ---- rendering ----
|
|
488
|
+
def _ensure_frame(self) -> None:
|
|
489
|
+
if self._mode == "unsupported":
|
|
490
|
+
return # nothing to rasterize; render_line shows the notice
|
|
491
|
+
w, h = self.size.width, self.size.height
|
|
492
|
+
if w <= 0 or h <= 0:
|
|
493
|
+
return
|
|
494
|
+
scale = self._active_scale()
|
|
495
|
+
key = (w, h, self._version, self._mode, scale)
|
|
496
|
+
if key == self._key:
|
|
497
|
+
return
|
|
498
|
+
self._key = key
|
|
499
|
+
if self._mode == "placeholder":
|
|
500
|
+
transmit, id_rgb, cells = self._plot.render_kitty_placeholder_cells(
|
|
501
|
+
w, h, self._cell_w, self._cell_h, scale=scale
|
|
502
|
+
)
|
|
503
|
+
self._transmit = transmit
|
|
504
|
+
self._cells = cells
|
|
505
|
+
self._style = Style(color=Color.from_rgb(*id_rgb))
|
|
506
|
+
elif self._mode == "direct":
|
|
507
|
+
# One escape draws the full-res image at the widget's origin,
|
|
508
|
+
# scaled to span its cell region. The fixed image id makes each
|
|
509
|
+
# frame replace the previous one atomically. compat_chunks: the
|
|
510
|
+
# direct tier exists for terminals (iTerm2) that need the image
|
|
511
|
+
# id repeated on every data chunk to assemble the transmission.
|
|
512
|
+
# tmux_wrap passes the APC through tmux (a no-op outside tmux),
|
|
513
|
+
# so the image reaches a browser terminal like xterm.js.
|
|
514
|
+
self._transmit = tmux_wrap(
|
|
515
|
+
self._plot.render_kitty(
|
|
516
|
+
w, h, self._cell_w, self._cell_h, compat_chunks=True, scale=scale,
|
|
517
|
+
replace=self._kitty_replace
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def _kitty_row_segments(self, y: int, w: int) -> list[Segment]:
|
|
522
|
+
"""One row of placeholder cells with overlay spans spliced in. Every
|
|
523
|
+
placeholder cell is self-addressed (it carries its own position
|
|
524
|
+
diacritics), so cells after a text gap still map to the right part of
|
|
525
|
+
the image."""
|
|
526
|
+
cells = self._cells[y] if self._cells is not None and y < len(self._cells) else None
|
|
527
|
+
if cells is None:
|
|
528
|
+
return [Segment(" " * max(0, w))]
|
|
529
|
+
spans = self._overlay.get(y)
|
|
530
|
+
if not spans:
|
|
531
|
+
return [Segment("".join(cells), self._style)]
|
|
532
|
+
segments: list[Segment] = []
|
|
533
|
+
cursor = 0
|
|
534
|
+
for col, text, style in spans:
|
|
535
|
+
if cursor < col:
|
|
536
|
+
segments.append(Segment("".join(cells[cursor:col]), self._style))
|
|
537
|
+
segments.append(Segment(text, style))
|
|
538
|
+
cursor = min(col + cell_len(text), len(cells))
|
|
539
|
+
if cursor < len(cells):
|
|
540
|
+
segments.append(Segment("".join(cells[cursor:]), self._style))
|
|
541
|
+
return segments
|
|
542
|
+
|
|
543
|
+
def _spliced_strip(self, strip: Strip, y: int, w: int) -> Strip:
|
|
544
|
+
"""A row strip with overlay spans spliced in (direct-mode rows)."""
|
|
545
|
+
spans = self._overlay.get(y)
|
|
546
|
+
if not spans:
|
|
547
|
+
return strip
|
|
548
|
+
segments: list[Segment] = []
|
|
549
|
+
cursor = 0
|
|
550
|
+
for col, text, style in spans:
|
|
551
|
+
if cursor < col:
|
|
552
|
+
segments.extend(strip.crop(cursor, col))
|
|
553
|
+
segments.append(Segment(text, style))
|
|
554
|
+
cursor = col + cell_len(text)
|
|
555
|
+
if cursor < w:
|
|
556
|
+
segments.extend(strip.crop(cursor, w))
|
|
557
|
+
return Strip(segments, w)
|
|
558
|
+
|
|
559
|
+
def _unsupported_line(self, y: int, w: int) -> Strip:
|
|
560
|
+
"""One row of the centered "this terminal can't do pixels" notice."""
|
|
561
|
+
h = self.size.height
|
|
562
|
+
top = max(0, (h - len(_UNSUPPORTED_MESSAGE)) // 2)
|
|
563
|
+
index = y - top
|
|
564
|
+
if 0 <= index < len(_UNSUPPORTED_MESSAGE):
|
|
565
|
+
text, style = _UNSUPPORTED_MESSAGE[index]
|
|
566
|
+
text = text[: max(0, w)]
|
|
567
|
+
pad_left = max(0, (w - len(text)) // 2)
|
|
568
|
+
pad_right = max(0, w - pad_left - len(text))
|
|
569
|
+
return Strip(
|
|
570
|
+
[Segment(" " * pad_left), Segment(text, style), Segment(" " * pad_right)], w
|
|
571
|
+
)
|
|
572
|
+
return Strip([Segment(" " * max(0, w))], w)
|
|
573
|
+
|
|
574
|
+
def render_line(self, y: int) -> Strip:
|
|
575
|
+
self._ensure_frame()
|
|
576
|
+
w = self.size.width
|
|
577
|
+
if self._mode == "unsupported":
|
|
578
|
+
return self._unsupported_line(y, w)
|
|
579
|
+
if self._mode == "placeholder":
|
|
580
|
+
segments = []
|
|
581
|
+
if y == 0 and self._transmit:
|
|
582
|
+
# Zero-width control segment carries the image upload. Reusing a
|
|
583
|
+
# fixed image id makes the terminal replace the frame atomically.
|
|
584
|
+
segments.append(Segment(self._transmit, None, [(0,)]))
|
|
585
|
+
segments.extend(self._kitty_row_segments(y, w))
|
|
586
|
+
return Strip(segments, w)
|
|
587
|
+
if self._mode == "direct":
|
|
588
|
+
segments = []
|
|
589
|
+
if y == 0 and self._transmit:
|
|
590
|
+
# The cursor sits at the widget's top-left when line 0 is
|
|
591
|
+
# written, which is exactly where the image escape draws
|
|
592
|
+
# (it saves/restores the cursor itself).
|
|
593
|
+
segments.append(Segment(self._transmit, None, [(0,)]))
|
|
594
|
+
# The cells under the image stay blank; overlays are still
|
|
595
|
+
# spliced, though most terminals draw the image above them —
|
|
596
|
+
# prefer the placeholder path for text-over-plot.
|
|
597
|
+
return Strip(
|
|
598
|
+
[*segments, *self._spliced_strip(Strip([Segment(" " * max(0, w))], w), y, w)], w
|
|
599
|
+
)
|
|
600
|
+
return Strip([Segment(" " * max(0, w))], w)
|
|
601
|
+
|
|
602
|
+
# ---- interaction ----
|
|
603
|
+
def _pixel_geometry(self, x: int, y: int) -> tuple[int, int, float, float, float]:
|
|
604
|
+
"""Map a cell coordinate into the framebuffer's pixel space:
|
|
605
|
+
`(px_w, px_h, px, py, node_radius)`."""
|
|
606
|
+
w, h = self.size.width, self.size.height
|
|
607
|
+
return (
|
|
608
|
+
w * self._cell_w,
|
|
609
|
+
h * self._cell_h,
|
|
610
|
+
x * self._cell_w + self._cell_w / 2,
|
|
611
|
+
y * self._cell_h + self._cell_h / 2,
|
|
612
|
+
float(self._cell_h),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def _pick_at(self, x: int, y: int) -> tuple[str, int] | None:
|
|
616
|
+
px_w, px_h, px, py, radius = self._pixel_geometry(x, y)
|
|
617
|
+
return self._plot.pick_element_px(px_w, px_h, px, py, radius)
|
|
618
|
+
|
|
619
|
+
def _set_hover(self, element: tuple[str, int] | None) -> None:
|
|
620
|
+
if element == self._hovered:
|
|
621
|
+
return
|
|
622
|
+
self._hovered = element
|
|
623
|
+
if self._plot.set_hovered(element):
|
|
624
|
+
self.invalidate()
|
|
625
|
+
self.post_message(self.ElementHovered(self, element))
|
|
626
|
+
|
|
627
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
628
|
+
if self._mode == "unsupported":
|
|
629
|
+
return
|
|
630
|
+
self._dragging = True
|
|
631
|
+
self._moved = False
|
|
632
|
+
self._last_pos = (event.screen_x, event.screen_y)
|
|
633
|
+
# A press on the range-slider strip grabs it instead of the camera; a
|
|
634
|
+
# track press jumps the window there and then drags it as the body.
|
|
635
|
+
if not self._plot.is_3d() and self._plot.range_slider():
|
|
636
|
+
px_w, px_h, px, py, _ = self._pixel_geometry(event.x, event.y)
|
|
637
|
+
hit = self._plot.range_slider_hit(px_w, px_h, px, py, float(self._cell_w))
|
|
638
|
+
if hit is not None:
|
|
639
|
+
if hit == "track":
|
|
640
|
+
if self._plot.jump_x_window(px_w, px_h, px):
|
|
641
|
+
self.invalidate()
|
|
642
|
+
hit = "window"
|
|
643
|
+
self._range_drag = hit
|
|
644
|
+
self.capture_mouse()
|
|
645
|
+
self.focus()
|
|
646
|
+
|
|
647
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
648
|
+
if self._mode == "unsupported":
|
|
649
|
+
return
|
|
650
|
+
if self._dragging:
|
|
651
|
+
# Deltas computed from screen coordinates, not event.delta_*:
|
|
652
|
+
# those are unreliable under mouse capture and in test pilots.
|
|
653
|
+
dx = event.screen_x - self._last_pos[0]
|
|
654
|
+
dy = event.screen_y - self._last_pos[1]
|
|
655
|
+
self._last_pos = (event.screen_x, event.screen_y)
|
|
656
|
+
if dx or dy:
|
|
657
|
+
self._moved = True
|
|
658
|
+
if self._range_drag is not None:
|
|
659
|
+
px_w, px_h, *_ = self._pixel_geometry(event.x, event.y)
|
|
660
|
+
if self._plot.drag_x_window(
|
|
661
|
+
px_w, px_h, self._range_drag, dx * self._cell_w
|
|
662
|
+
):
|
|
663
|
+
self.invalidate()
|
|
664
|
+
elif (
|
|
665
|
+
not event.shift
|
|
666
|
+
and not self._plot.is_3d()
|
|
667
|
+
and self._plot.x_window() is not None
|
|
668
|
+
):
|
|
669
|
+
# With a window set, a plain plot-area drag slides the window
|
|
670
|
+
# (the camera is superseded).
|
|
671
|
+
px_w, px_h, *_ = self._pixel_geometry(event.x, event.y)
|
|
672
|
+
if self._plot.pan_x_window(px_w, px_h, dx * self._cell_w):
|
|
673
|
+
self.invalidate()
|
|
674
|
+
else:
|
|
675
|
+
# Routed through the plot's input map: drag rotates
|
|
676
|
+
# (trackball — drag right turns the object right),
|
|
677
|
+
# shift-drag pans, unless remapped via plot.set_input_map.
|
|
678
|
+
# Pan is in full-resolution image pixels, so one dragged
|
|
679
|
+
# cell is one cell's worth of pixels and the plot stays
|
|
680
|
+
# under the pointer.
|
|
681
|
+
self._apply_mapped_drag(
|
|
682
|
+
dx, dy, event.shift, 0.03, self._cell_w, self._cell_h, 0.15
|
|
683
|
+
)
|
|
684
|
+
elif not self._plot.is_3d():
|
|
685
|
+
if self._crosshair:
|
|
686
|
+
px_w, px_h, px, py, _ = self._pixel_geometry(event.x, event.y)
|
|
687
|
+
if self._plot.set_hover2d(px):
|
|
688
|
+
self.invalidate()
|
|
689
|
+
elif self._pickable:
|
|
690
|
+
self._set_hover(self._pick_at(event.x, event.y))
|
|
691
|
+
|
|
692
|
+
def on_leave(self, event: events.Leave) -> None:
|
|
693
|
+
if self._pickable:
|
|
694
|
+
self._set_hover(None)
|
|
695
|
+
if self._crosshair and self._plot.set_hover2d(None):
|
|
696
|
+
self.invalidate()
|
|
697
|
+
|
|
698
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
699
|
+
was_click = self._dragging and not self._moved
|
|
700
|
+
was_drag = self._dragging and self._moved
|
|
701
|
+
was_range = self._dragging and self._range_drag is not None
|
|
702
|
+
self._dragging = False
|
|
703
|
+
self._range_drag = None
|
|
704
|
+
self.release_mouse()
|
|
705
|
+
if was_range:
|
|
706
|
+
# The strip gesture ended: one message with the result.
|
|
707
|
+
self.invalidate()
|
|
708
|
+
self.post_message(self.RangeChanged(self, self._plot.x_window()))
|
|
709
|
+
elif was_click:
|
|
710
|
+
self.on_click_at(event)
|
|
711
|
+
elif was_drag:
|
|
712
|
+
# The gesture ended: repaint so a half-res interaction frame is
|
|
713
|
+
# replaced by a crisp full-res one.
|
|
714
|
+
self.invalidate()
|
|
715
|
+
|
|
716
|
+
def _scroll(self, event, factor: float) -> None:
|
|
717
|
+
# With an x window set on a 2D plot the wheel zooms the window about
|
|
718
|
+
# the cursor; otherwise it zooms the camera.
|
|
719
|
+
if not self._plot.is_3d() and self._plot.x_window() is not None:
|
|
720
|
+
px_w, px_h, px, *_ = self._pixel_geometry(event.x, event.y)
|
|
721
|
+
if self._plot.zoom_x_window(px_w, px_h, px, factor):
|
|
722
|
+
self.invalidate()
|
|
723
|
+
self.post_message(self.RangeChanged(self, self._plot.x_window()))
|
|
724
|
+
return
|
|
725
|
+
self.apply_zoom(factor)
|
|
726
|
+
|
|
727
|
+
def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
|
728
|
+
if self._mode == "unsupported":
|
|
729
|
+
return
|
|
730
|
+
self._scroll(event, 0.9)
|
|
731
|
+
|
|
732
|
+
def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
733
|
+
if self._mode == "unsupported":
|
|
734
|
+
return
|
|
735
|
+
self._scroll(event, 1.1)
|
|
736
|
+
|
|
737
|
+
def on_key(self, event: events.Key) -> None:
|
|
738
|
+
if self._mode == "unsupported":
|
|
739
|
+
return
|
|
740
|
+
key = event.key
|
|
741
|
+
if key in ("plus", "equals_sign", "="):
|
|
742
|
+
self.apply_zoom(1.1)
|
|
743
|
+
elif key in ("minus", "-"):
|
|
744
|
+
self.apply_zoom(0.9)
|
|
745
|
+
elif key == "left":
|
|
746
|
+
self.apply_rotate(0.1, 0.0)
|
|
747
|
+
elif key == "right":
|
|
748
|
+
self.apply_rotate(-0.1, 0.0)
|
|
749
|
+
elif key == "up":
|
|
750
|
+
self.apply_rotate(0.0, 0.1)
|
|
751
|
+
elif key == "down":
|
|
752
|
+
self.apply_rotate(0.0, -0.1)
|
|
753
|
+
elif key == "shift+left":
|
|
754
|
+
self.apply_pan(-2.0 * self._cell_w, 0.0)
|
|
755
|
+
elif key == "shift+right":
|
|
756
|
+
self.apply_pan(2.0 * self._cell_w, 0.0)
|
|
757
|
+
elif key == "shift+up":
|
|
758
|
+
self.apply_pan(0.0, -2.0 * self._cell_h)
|
|
759
|
+
elif key == "shift+down":
|
|
760
|
+
self.apply_pan(0.0, 2.0 * self._cell_h)
|
|
761
|
+
elif key in ("left_square_bracket", "right_square_bracket", "[", "]"):
|
|
762
|
+
if self._plot.x_window() is None:
|
|
763
|
+
return
|
|
764
|
+
frac = -0.1 if key in ("left_square_bracket", "[") else 0.1
|
|
765
|
+
if self._plot.shift_x_window(frac):
|
|
766
|
+
self.invalidate()
|
|
767
|
+
self.post_message(self.RangeChanged(self, self._plot.x_window()))
|
|
768
|
+
elif key == "r":
|
|
769
|
+
self.apply_reset()
|
|
770
|
+
else:
|
|
771
|
+
return
|
|
772
|
+
event.stop()
|