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