vimol 0.1.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.
- vimol/__init__.py +63 -0
- vimol/__main__.py +4 -0
- vimol/app.py +218 -0
- vimol/arrows.py +91 -0
- vimol/bonds.py +79 -0
- vimol/camera.py +77 -0
- vimol/editor.py +646 -0
- vimol/elements.py +123 -0
- vimol/gl_adapter.py +114 -0
- vimol/gl_render.py +742 -0
- vimol/input.py +252 -0
- vimol/kitty.py +297 -0
- vimol/molecule.py +159 -0
- vimol/parsers/__init__.py +88 -0
- vimol/parsers/mol.py +57 -0
- vimol/parsers/pdb.py +97 -0
- vimol/parsers/xyz.py +68 -0
- vimol/periodic_table.py +97 -0
- vimol/py.typed +0 -0
- vimol/render.py +600 -0
- vimol/scene.py +239 -0
- vimol/templates.py +205 -0
- vimol/viewer.py +1159 -0
- vimol/widget.py +611 -0
- vimol-0.1.0.dist-info/METADATA +302 -0
- vimol-0.1.0.dist-info/RECORD +30 -0
- vimol-0.1.0.dist-info/WHEEL +5 -0
- vimol-0.1.0.dist-info/entry_points.txt +2 -0
- vimol-0.1.0.dist-info/licenses/LICENSE +20 -0
- vimol-0.1.0.dist-info/top_level.txt +1 -0
vimol/input.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Terminal input decoding: keys and SGR mouse events (cell *and* pixel).
|
|
2
|
+
|
|
3
|
+
The decoder is stateful so it tolerates escape sequences that get split across
|
|
4
|
+
reads. It is deliberately independent of the viewer: an embedding application
|
|
5
|
+
can create an :class:`InputDecoder`, feed it whatever bytes it intercepts, and
|
|
6
|
+
forward the resulting :class:`MouseEvent` / :class:`KeyEvent` objects to a
|
|
7
|
+
:class:`vimol.widget.MoleculeWidget` — so you can capture the mouse inside
|
|
8
|
+
your own UI region and drive the molecule with it.
|
|
9
|
+
|
|
10
|
+
Mouse coordinates come back in *pixels* when the terminal supports SGR-Pixels
|
|
11
|
+
(DECSET 1016, a Kitty extension also in Ghostty/WezTerm/foot) and in character
|
|
12
|
+
cells otherwise; the ``pixel`` flag on each event says which.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import select
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import List, Optional, Union
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
# Events
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
@dataclass
|
|
26
|
+
class MouseEvent:
|
|
27
|
+
action: str # 'down' | 'up' | 'drag' | 'move' | 'scroll'
|
|
28
|
+
x: float # pixels if pixel else 0-based cell column
|
|
29
|
+
y: float # pixels if pixel else 0-based cell row
|
|
30
|
+
button: Optional[int] = None # 0 left, 1 middle, 2 right (None for move/scroll)
|
|
31
|
+
scroll: Optional[str] = None # 'up' | 'down' | 'left' | 'right'
|
|
32
|
+
shift: bool = False
|
|
33
|
+
alt: bool = False
|
|
34
|
+
ctrl: bool = False
|
|
35
|
+
pixel: bool = False # True -> x/y are pixels, False -> x/y are cells
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class KeyEvent:
|
|
40
|
+
key: str # a single char, or a name: 'up' 'down' 'left' 'right'
|
|
41
|
+
# 'escape' 'enter' 'tab' 'backspace'
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return self.key
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Event = Union[MouseEvent, KeyEvent]
|
|
47
|
+
|
|
48
|
+
_CSI_FINAL = {"A": "up", "B": "down", "C": "right", "D": "left",
|
|
49
|
+
"H": "home", "F": "end"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------
|
|
53
|
+
# Mouse enable / disable control sequences
|
|
54
|
+
# --------------------------------------------------------------------------
|
|
55
|
+
def enable_mouse(pixel: bool = True, hover: bool = False) -> bytes:
|
|
56
|
+
"""Bytes to turn on SGR mouse reporting.
|
|
57
|
+
|
|
58
|
+
hover=True uses any-event tracking (1003) so motion is reported even with no
|
|
59
|
+
button held (needed for hover picking); otherwise button-event tracking
|
|
60
|
+
(1002) reports motion only while dragging. pixel=True additionally requests
|
|
61
|
+
SGR-Pixels (1016) for pixel-precise coordinates.
|
|
62
|
+
"""
|
|
63
|
+
motion = "1003" if hover else "1002"
|
|
64
|
+
seq = f"\x1b[?1000;{motion};1006h"
|
|
65
|
+
if pixel:
|
|
66
|
+
seq += "\x1b[?1016h"
|
|
67
|
+
return seq.encode()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def disable_mouse(pixel: bool = True) -> bytes:
|
|
71
|
+
seq = "\x1b[?1000;1002;1003;1006l"
|
|
72
|
+
if pixel:
|
|
73
|
+
seq += "\x1b[?1016l"
|
|
74
|
+
return seq.encode()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def query_decset(mode: int, fd_in: int = 0, fd_out: int = 1, timeout: float = 0.2):
|
|
78
|
+
"""Probe a DECSET private mode via DECRQM; return its reported value.
|
|
79
|
+
|
|
80
|
+
Returns the numeric value from the terminal's ``CSI ? mode ; value $y``
|
|
81
|
+
reply (1=set, 2=reset, 3=perm-set, 4=perm-reset, 0=not recognized) or None
|
|
82
|
+
if the terminal did not answer. Requires the tty to be in raw mode.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
os.write(fd_out, f"\x1b[?{mode}$p".encode())
|
|
86
|
+
except OSError:
|
|
87
|
+
return None
|
|
88
|
+
buf = b""
|
|
89
|
+
end_marker = b"$y"
|
|
90
|
+
import time as _time
|
|
91
|
+
# We cannot call time.time() indirectly? it's fine here (not a workflow script).
|
|
92
|
+
deadline = _time.monotonic() + timeout
|
|
93
|
+
while _time.monotonic() < deadline:
|
|
94
|
+
r, _, _ = select.select([fd_in], [], [], max(0.0, deadline - _time.monotonic()))
|
|
95
|
+
if not r:
|
|
96
|
+
break
|
|
97
|
+
try:
|
|
98
|
+
chunk = os.read(fd_in, 256)
|
|
99
|
+
except OSError:
|
|
100
|
+
break
|
|
101
|
+
if not chunk:
|
|
102
|
+
break
|
|
103
|
+
buf += chunk
|
|
104
|
+
if end_marker in buf:
|
|
105
|
+
break
|
|
106
|
+
# parse: ESC [ ? <mode> ; <value> $ y
|
|
107
|
+
marker = f"\x1b[?{mode};".encode()
|
|
108
|
+
i = buf.find(marker)
|
|
109
|
+
if i < 0:
|
|
110
|
+
return None
|
|
111
|
+
j = buf.find(end_marker, i)
|
|
112
|
+
if j < 0:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
val = int(buf[i + len(marker):j])
|
|
116
|
+
except ValueError:
|
|
117
|
+
return None
|
|
118
|
+
return val
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def supports_pixel_mouse(fd_in: int = 0, fd_out: int = 1, timeout: float = 0.5) -> bool:
|
|
122
|
+
"""True if the terminal reports SGR-Pixels (1016) as a recognized mode.
|
|
123
|
+
|
|
124
|
+
The default timeout is generous (0.5s) on purpose: the DECRQM round-trip
|
|
125
|
+
is one-time startup cost, but over SSH a tight timeout loses the race and
|
|
126
|
+
reports "no pixel mouse" for a terminal that in fact supports it. A wrong
|
|
127
|
+
answer here no longer breaks the mouse -- the viewer keeps the wire format
|
|
128
|
+
and the decoder consistent either way (see Viewer._setup_mouse) -- but a
|
|
129
|
+
right answer preserves pixel-precise dragging and picking over SSH.
|
|
130
|
+
"""
|
|
131
|
+
val = query_decset(1016, fd_in, fd_out, timeout=timeout)
|
|
132
|
+
return val in (1, 2, 3, 4)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
# Decoder
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
class InputDecoder:
|
|
139
|
+
"""Turn raw terminal bytes into :class:`Event` objects.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
pixel:
|
|
144
|
+
Whether SGR mouse coordinates should be interpreted as pixels. Set this
|
|
145
|
+
to match whether you enabled mode 1016 (see :func:`supports_pixel_mouse`).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, pixel: bool = False):
|
|
149
|
+
self.pixel = pixel
|
|
150
|
+
self._buf = bytearray()
|
|
151
|
+
|
|
152
|
+
def feed(self, data: bytes) -> List[Event]:
|
|
153
|
+
self._buf += data
|
|
154
|
+
events: List[Event] = []
|
|
155
|
+
while self._buf:
|
|
156
|
+
ev, consumed = self._parse_one()
|
|
157
|
+
if consumed == 0:
|
|
158
|
+
break # incomplete sequence, wait for more bytes
|
|
159
|
+
if ev is not None:
|
|
160
|
+
events.append(ev)
|
|
161
|
+
del self._buf[:consumed]
|
|
162
|
+
return events
|
|
163
|
+
|
|
164
|
+
def flush(self) -> List[Event]:
|
|
165
|
+
"""Emit any pending unterminated sequence (e.g. a lone ESC -> escape)."""
|
|
166
|
+
if len(self._buf) == 1 and self._buf[0] == 0x1B:
|
|
167
|
+
self._buf.clear()
|
|
168
|
+
return [KeyEvent("escape")]
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
# -- internals --------------------------------------------------------
|
|
172
|
+
def _parse_one(self):
|
|
173
|
+
b = self._buf
|
|
174
|
+
c = b[0]
|
|
175
|
+
if c != 0x1B:
|
|
176
|
+
return self._parse_key_byte(c), 1
|
|
177
|
+
# escape sequence
|
|
178
|
+
if len(b) < 2:
|
|
179
|
+
return None, 0 # wait; flush() handles a lingering lone ESC
|
|
180
|
+
if b[1] == ord("["):
|
|
181
|
+
return self._parse_csi()
|
|
182
|
+
if b[1] == ord("O"): # SS3 (application arrows)
|
|
183
|
+
if len(b) < 3:
|
|
184
|
+
return None, 0
|
|
185
|
+
name = _CSI_FINAL.get(chr(b[2]))
|
|
186
|
+
return (KeyEvent(name) if name else None), 3
|
|
187
|
+
# Alt+key
|
|
188
|
+
return KeyEvent(chr(b[1])), 2
|
|
189
|
+
|
|
190
|
+
def _parse_key_byte(self, c: int):
|
|
191
|
+
if c == 0x0D:
|
|
192
|
+
return KeyEvent("enter")
|
|
193
|
+
if c == 0x09:
|
|
194
|
+
return KeyEvent("tab")
|
|
195
|
+
if c in (0x7F, 0x08):
|
|
196
|
+
return KeyEvent("backspace")
|
|
197
|
+
return KeyEvent(chr(c))
|
|
198
|
+
|
|
199
|
+
def _parse_csi(self):
|
|
200
|
+
b = self._buf
|
|
201
|
+
n = len(b)
|
|
202
|
+
# SGR mouse: ESC [ < params (M|m)
|
|
203
|
+
if n >= 3 and b[2] == ord("<"):
|
|
204
|
+
k = 3
|
|
205
|
+
while k < n and b[k] not in (ord("M"), ord("m")):
|
|
206
|
+
k += 1
|
|
207
|
+
if k >= n:
|
|
208
|
+
return None, 0 # incomplete
|
|
209
|
+
body = bytes(b[3:k]).decode("ascii", "ignore")
|
|
210
|
+
final = chr(b[k])
|
|
211
|
+
return self._decode_sgr_mouse(body, final), k + 1
|
|
212
|
+
# generic CSI: read until final byte in @..~
|
|
213
|
+
k = 2
|
|
214
|
+
while k < n and not (0x40 <= b[k] <= 0x7E):
|
|
215
|
+
k += 1
|
|
216
|
+
if k >= n:
|
|
217
|
+
return None, 0
|
|
218
|
+
final = chr(b[k])
|
|
219
|
+
name = _CSI_FINAL.get(final)
|
|
220
|
+
return (KeyEvent(name) if name else None), k + 1
|
|
221
|
+
|
|
222
|
+
def _decode_sgr_mouse(self, body: str, final: str) -> Optional[MouseEvent]:
|
|
223
|
+
try:
|
|
224
|
+
bs, xs, ys = body.split(";")
|
|
225
|
+
code = int(bs); x = int(xs); y = int(ys)
|
|
226
|
+
except ValueError:
|
|
227
|
+
return None
|
|
228
|
+
shift = bool(code & 4)
|
|
229
|
+
alt = bool(code & 8)
|
|
230
|
+
ctrl = bool(code & 16)
|
|
231
|
+
wheel = bool(code & 64)
|
|
232
|
+
motion = bool(code & 32)
|
|
233
|
+
low = code & 3
|
|
234
|
+
# coordinates: SGR is 1-based; make cells 0-based. Pixels stay as-is.
|
|
235
|
+
if self.pixel:
|
|
236
|
+
px, py = float(x), float(y)
|
|
237
|
+
else:
|
|
238
|
+
px, py = float(x - 1), float(y - 1)
|
|
239
|
+
|
|
240
|
+
if wheel:
|
|
241
|
+
direction = {0: "up", 1: "down", 2: "left", 3: "right"}.get(low, "up")
|
|
242
|
+
return MouseEvent("scroll", px, py, scroll=direction,
|
|
243
|
+
shift=shift, alt=alt, ctrl=ctrl, pixel=self.pixel)
|
|
244
|
+
if motion:
|
|
245
|
+
if low == 3:
|
|
246
|
+
return MouseEvent("move", px, py, button=None,
|
|
247
|
+
shift=shift, alt=alt, ctrl=ctrl, pixel=self.pixel)
|
|
248
|
+
return MouseEvent("drag", px, py, button=low,
|
|
249
|
+
shift=shift, alt=alt, ctrl=ctrl, pixel=self.pixel)
|
|
250
|
+
action = "down" if final == "M" else "up"
|
|
251
|
+
return MouseEvent(action, px, py, button=low,
|
|
252
|
+
shift=shift, alt=alt, ctrl=ctrl, pixel=self.pixel)
|
vimol/kitty.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Kitty terminal graphics protocol encoder + terminal geometry helpers.
|
|
2
|
+
|
|
3
|
+
Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
4
|
+
|
|
5
|
+
We transmit raw RGB/RGBA pixels (optionally zlib-compressed) in <=4096-byte
|
|
6
|
+
base64 chunks. The public surface is small on purpose so the module can be
|
|
7
|
+
lifted into other terminal apps unchanged.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import zlib
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
_ESC = b"\x1b"
|
|
20
|
+
_GRAPHICS_START = b"\x1b_G"
|
|
21
|
+
_GRAPHICS_END = b"\x1b\\"
|
|
22
|
+
_CHUNK = 4096
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------
|
|
26
|
+
# Terminal geometry
|
|
27
|
+
# --------------------------------------------------------------------------
|
|
28
|
+
def terminal_size_px(fd: int = 1) -> Tuple[int, int, int, int]:
|
|
29
|
+
"""Return (cols, rows, width_px, height_px) for the terminal on *fd*.
|
|
30
|
+
|
|
31
|
+
Falls back to environment / sane defaults when the ioctl is unavailable
|
|
32
|
+
(e.g. output is not a tty). width/height px may be 0 if the terminal does
|
|
33
|
+
not report pixel dimensions.
|
|
34
|
+
"""
|
|
35
|
+
cols, rows, xpx, ypx = 80, 24, 0, 0
|
|
36
|
+
try:
|
|
37
|
+
import fcntl
|
|
38
|
+
import struct
|
|
39
|
+
import termios
|
|
40
|
+
|
|
41
|
+
buf = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
|
|
42
|
+
rows, cols, xpx, ypx = struct.unpack("HHHH", buf)
|
|
43
|
+
except Exception:
|
|
44
|
+
cols = int(os.environ.get("COLUMNS", cols))
|
|
45
|
+
rows = int(os.environ.get("LINES", rows))
|
|
46
|
+
return cols, rows, xpx, ypx
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cell_size_px(fd: int = 1) -> Tuple[float, float]:
|
|
50
|
+
"""(cell_width_px, cell_height_px). Defaults to 9x18 if unknown.
|
|
51
|
+
|
|
52
|
+
Derived by dividing the window's reported pixel extent by its cell
|
|
53
|
+
count. That extent can include the terminal's window padding, which
|
|
54
|
+
this then smears across every row/column as a fractional per-cell
|
|
55
|
+
error -- invisible to the continuous, radius-based atom picker but
|
|
56
|
+
enough to make a rigid per-cell grid (the periodic-table picker) land
|
|
57
|
+
on the wrong cell, worse the further down you go as the error adds up.
|
|
58
|
+
Rounding removes the smaller part of that noise; :func:`query_cell_size_px`
|
|
59
|
+
removes it entirely by asking the terminal for the exact cell size, and
|
|
60
|
+
the viewer prefers that when the terminal answers.
|
|
61
|
+
"""
|
|
62
|
+
cols, rows, xpx, ypx = terminal_size_px(fd)
|
|
63
|
+
cw = round(xpx / cols) if xpx and cols else 9.0
|
|
64
|
+
ch = round(ypx / rows) if ypx and rows else 18.0
|
|
65
|
+
return float(cw), float(ch)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def query_cell_size_px(fd_in: int = 0, fd_out: int = 1, timeout: float = 0.2):
|
|
69
|
+
"""Ask the terminal for its exact cell size in pixels via ``CSI 16 t``.
|
|
70
|
+
|
|
71
|
+
Returns ``(cell_width_px, cell_height_px)`` from the terminal's
|
|
72
|
+
``CSI 6 ; height ; width t`` reply, or ``None`` if it doesn't answer.
|
|
73
|
+
Unlike :func:`cell_size_px` this is the terminal's own authoritative
|
|
74
|
+
cell metric, with no window-padding contamination -- so cell/pixel
|
|
75
|
+
hit-testing lines up exactly with where glyphs are actually drawn.
|
|
76
|
+
Requires the tty to be in raw mode (mirrors input.query_decset).
|
|
77
|
+
"""
|
|
78
|
+
import select
|
|
79
|
+
import time as _time
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
os.write(fd_out, b"\x1b[16t")
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
buf = b""
|
|
86
|
+
deadline = _time.monotonic() + timeout
|
|
87
|
+
while _time.monotonic() < deadline:
|
|
88
|
+
r, _, _ = select.select([fd_in], [], [], max(0.0, deadline - _time.monotonic()))
|
|
89
|
+
if not r:
|
|
90
|
+
break
|
|
91
|
+
try:
|
|
92
|
+
chunk = os.read(fd_in, 64)
|
|
93
|
+
except OSError:
|
|
94
|
+
break
|
|
95
|
+
if not chunk:
|
|
96
|
+
break
|
|
97
|
+
buf += chunk
|
|
98
|
+
if b"t" in buf and b"\x1b[6;" in buf:
|
|
99
|
+
break
|
|
100
|
+
# parse: ESC [ 6 ; <height> ; <width> t
|
|
101
|
+
marker = b"\x1b[6;"
|
|
102
|
+
i = buf.find(marker)
|
|
103
|
+
if i < 0:
|
|
104
|
+
return None
|
|
105
|
+
j = buf.find(b"t", i)
|
|
106
|
+
if j < 0:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
h_str, w_str = buf[i + len(marker):j].split(b";")
|
|
110
|
+
ch = float(int(h_str))
|
|
111
|
+
cw = float(int(w_str))
|
|
112
|
+
except (ValueError, IndexError):
|
|
113
|
+
return None
|
|
114
|
+
if cw <= 0 or ch <= 0:
|
|
115
|
+
return None
|
|
116
|
+
return cw, ch
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def unique_id_base(stride: int = 4) -> int:
|
|
120
|
+
"""A per-process base for Kitty graphics ``i=``/``p=`` ids.
|
|
121
|
+
|
|
122
|
+
Image ids live in a namespace global to the whole kitty *process* (shared
|
|
123
|
+
across all its panes/tabs), not per-window — see the graphics protocol
|
|
124
|
+
spec's note that "IDs are in a global namespace [so] there can easily be
|
|
125
|
+
collisions." Hardcoding small ids like 1/2 means two independent
|
|
126
|
+
vimol instances sharing a kitty process (e.g. two panes) can delete or
|
|
127
|
+
overwrite each other's frames. Deriving the base from the pid keeps
|
|
128
|
+
concurrent instances apart; the stride ensures that even OS-assigned
|
|
129
|
+
sequential pids (common right after spawning several processes) don't
|
|
130
|
+
produce adjacent, overlapping id ranges.
|
|
131
|
+
"""
|
|
132
|
+
pid = os.getpid()
|
|
133
|
+
return ((pid * stride) % 0x7FFFFFFF) or 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def supports_kitty() -> bool:
|
|
137
|
+
"""Best-effort detection of Kitty graphics support via environment."""
|
|
138
|
+
if os.environ.get("VIMOL_FORCE_KITTY"):
|
|
139
|
+
return True
|
|
140
|
+
if os.environ.get("KITTY_WINDOW_ID"):
|
|
141
|
+
return True
|
|
142
|
+
term = os.environ.get("TERM", "")
|
|
143
|
+
if "kitty" in term or "ghostty" in term:
|
|
144
|
+
return True
|
|
145
|
+
prog = os.environ.get("TERM_PROGRAM", "").lower()
|
|
146
|
+
if prog in ("ghostty", "wezterm"):
|
|
147
|
+
return True
|
|
148
|
+
if os.environ.get("WEZTERM_PANE"):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
# Image encoding
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
def _controls(d: dict) -> bytes:
|
|
157
|
+
return ",".join(f"{k}={v}" for k, v in d.items()).encode("ascii")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def encode_image(
|
|
161
|
+
pixels: np.ndarray,
|
|
162
|
+
*,
|
|
163
|
+
image_id: int = 1,
|
|
164
|
+
placement_id: Optional[int] = None,
|
|
165
|
+
cols: Optional[int] = None,
|
|
166
|
+
rows: Optional[int] = None,
|
|
167
|
+
move_cursor: bool = False,
|
|
168
|
+
compress: bool = True,
|
|
169
|
+
compress_level: int = 6,
|
|
170
|
+
z_index: int = 0,
|
|
171
|
+
quiet: int = 2,
|
|
172
|
+
) -> bytes:
|
|
173
|
+
"""Encode an (H, W, 3|4) uint8 array as Kitty graphics-protocol bytes.
|
|
174
|
+
|
|
175
|
+
cols/rows scale the image into that many terminal cells (defaults to the
|
|
176
|
+
image's native pixel size). When *move_cursor* is False (C=1) the cursor is
|
|
177
|
+
left in place, which is what you want when compositing a UI around the
|
|
178
|
+
image. placement_id defaults to image_id (placements are scoped to their
|
|
179
|
+
image, so this is always collision-safe).
|
|
180
|
+
|
|
181
|
+
*compress_level* is the zlib level (0-9). The compress step costs about as
|
|
182
|
+
much per frame as the whole raycast, so interactive callers pass level 1
|
|
183
|
+
(roughly half the CPU of the default 6 for ~25% more bytes -- a fine
|
|
184
|
+
trade for a local terminal); a resting still can afford the default for a
|
|
185
|
+
smaller payload.
|
|
186
|
+
"""
|
|
187
|
+
if placement_id is None:
|
|
188
|
+
placement_id = image_id
|
|
189
|
+
arr = np.ascontiguousarray(pixels, dtype=np.uint8)
|
|
190
|
+
h, w = arr.shape[0], arr.shape[1]
|
|
191
|
+
fmt = 32 if arr.ndim == 3 and arr.shape[2] == 4 else 24
|
|
192
|
+
raw = arr.tobytes()
|
|
193
|
+
if compress:
|
|
194
|
+
raw = zlib.compress(raw, compress_level)
|
|
195
|
+
payload = base64.standard_b64encode(raw)
|
|
196
|
+
|
|
197
|
+
ctrl = {
|
|
198
|
+
"a": "T", # transmit and display
|
|
199
|
+
"f": fmt, # 24=RGB, 32=RGBA
|
|
200
|
+
"s": w,
|
|
201
|
+
"v": h,
|
|
202
|
+
"i": image_id,
|
|
203
|
+
"p": placement_id,
|
|
204
|
+
"q": quiet, # 0=verbose, 1=no ok, 2=no ok/err
|
|
205
|
+
}
|
|
206
|
+
if compress:
|
|
207
|
+
ctrl["o"] = "z"
|
|
208
|
+
if cols:
|
|
209
|
+
ctrl["c"] = int(cols)
|
|
210
|
+
if rows:
|
|
211
|
+
ctrl["r"] = int(rows)
|
|
212
|
+
if not move_cursor:
|
|
213
|
+
ctrl["C"] = 1
|
|
214
|
+
if z_index:
|
|
215
|
+
ctrl["z"] = int(z_index)
|
|
216
|
+
|
|
217
|
+
out = bytearray()
|
|
218
|
+
# chunked transfer: first chunk carries controls, subsequent carry only m=
|
|
219
|
+
if len(payload) <= _CHUNK:
|
|
220
|
+
ctrl["m"] = 0
|
|
221
|
+
out += _GRAPHICS_START + _controls(ctrl) + b";" + payload + _GRAPHICS_END
|
|
222
|
+
return bytes(out)
|
|
223
|
+
|
|
224
|
+
first = True
|
|
225
|
+
view = memoryview(payload)
|
|
226
|
+
n = len(payload)
|
|
227
|
+
pos = 0
|
|
228
|
+
while pos < n:
|
|
229
|
+
chunk = view[pos:pos + _CHUNK]
|
|
230
|
+
pos += _CHUNK
|
|
231
|
+
last = pos >= n
|
|
232
|
+
if first:
|
|
233
|
+
c = dict(ctrl)
|
|
234
|
+
c["m"] = 0 if last else 1
|
|
235
|
+
out += _GRAPHICS_START + _controls(c) + b";" + bytes(chunk) + _GRAPHICS_END
|
|
236
|
+
first = False
|
|
237
|
+
else:
|
|
238
|
+
m = 0 if last else 1
|
|
239
|
+
out += _GRAPHICS_START + f"m={m}".encode() + b";" + bytes(chunk) + _GRAPHICS_END
|
|
240
|
+
return bytes(out)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def delete_image(image_id: int = 1) -> bytes:
|
|
244
|
+
"""Bytes to delete a transmitted image (and its placements) by id."""
|
|
245
|
+
return _GRAPHICS_START + _controls({"a": "d", "d": "I", "i": image_id, "q": 2}) + b";" + _GRAPHICS_END
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def clear_all_images() -> bytes:
|
|
249
|
+
return _GRAPHICS_START + b"a=d,d=A,q=2;" + _GRAPHICS_END
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def set_pointer_shape(shape: str) -> bytes:
|
|
253
|
+
"""OSC 22 bytes to PUSH the OS mouse-pointer icon to a CSS-cursor *shape*.
|
|
254
|
+
|
|
255
|
+
Kitty-family terminals honor this while focused; others ignore an OSC they
|
|
256
|
+
don't recognize, so it's safe to write unconditionally (like the mouse-mode
|
|
257
|
+
sequences). Pushing (rather than a plain set) pairs with
|
|
258
|
+
:func:`reset_pointer_shape`'s pop, so disarming restores whatever shape
|
|
259
|
+
was actually active before -- not just the terminal's generic default,
|
|
260
|
+
which a plain set/reset pair would revert to instead. Returns raw bytes
|
|
261
|
+
for the caller to write.
|
|
262
|
+
"""
|
|
263
|
+
return f"\x1b]22;>{shape}\x1b\\".encode()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def reset_pointer_shape() -> bytes:
|
|
267
|
+
"""OSC 22 bytes to POP the pointer shape pushed by :func:`set_pointer_shape`."""
|
|
268
|
+
return b"\x1b]22;<\x1b\\"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def write_bytes(data: bytes, fd: int = 1) -> None:
|
|
272
|
+
os.write(fd, data)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def png_bytes(pixels: np.ndarray) -> bytes:
|
|
276
|
+
"""Encode an (H, W, 3|4) uint8 array as a PNG (stdlib zlib, no Pillow)."""
|
|
277
|
+
import struct
|
|
278
|
+
|
|
279
|
+
arr = np.ascontiguousarray(pixels, np.uint8)
|
|
280
|
+
h, w = arr.shape[0], arr.shape[1]
|
|
281
|
+
channels = arr.shape[2] if arr.ndim == 3 else 1
|
|
282
|
+
color_type = {1: 0, 3: 2, 4: 6}[channels]
|
|
283
|
+
|
|
284
|
+
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
285
|
+
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
286
|
+
|
|
287
|
+
sig = b"\x89PNG\r\n\x1a\n"
|
|
288
|
+
ihdr = struct.pack(">IIBBBBB", w, h, 8, color_type, 0, 0, 0)
|
|
289
|
+
# add filter byte 0 per scanline
|
|
290
|
+
stride = w * channels
|
|
291
|
+
raw = bytearray()
|
|
292
|
+
flat = arr.reshape(h, stride)
|
|
293
|
+
for y in range(h):
|
|
294
|
+
raw.append(0)
|
|
295
|
+
raw += flat[y].tobytes()
|
|
296
|
+
idat = zlib.compress(bytes(raw), 9)
|
|
297
|
+
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|