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/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()