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/viewer.py ADDED
@@ -0,0 +1,1159 @@
1
+ """Full-screen interactive viewer — a thin driver around MoleculeWidget.
2
+
3
+ All the interaction logic lives in :class:`vimol.widget.MoleculeWidget` and
4
+ input decoding in :class:`vimol.input.InputDecoder`. This class only does the
5
+ terminal-owning parts: raw mode, the alternate screen, enabling mouse
6
+ reporting, the render/input loop, and a status bar. Embedders who want to
7
+ capture the mouse in their own region should use the widget + decoder directly
8
+ (see examples/embed_demo.py) rather than this driver.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import select
14
+ import time
15
+ from typing import List, Optional, Tuple
16
+
17
+ from .molecule import Molecule
18
+ from .render import Style
19
+ from .widget import MoleculeWidget, REPRESENTATIONS
20
+ from .bonds import ensure_bonds
21
+ from . import editor
22
+ from . import kitty
23
+ from . import input as _input
24
+ from . import elements
25
+ from . import templates
26
+ from . import periodic_table
27
+
28
+ # ANSI / terminal control -------------------------------------------------
29
+ _ALT_SCREEN_ON = b"\x1b[?1049h"
30
+ _ALT_SCREEN_OFF = b"\x1b[?1049l"
31
+ _HIDE_CURSOR = b"\x1b[?25l"
32
+ _SHOW_CURSOR = b"\x1b[?25h"
33
+ _CLEAR = b"\x1b[2J"
34
+ _HOME = b"\x1b[H"
35
+ # The Kitty graphics protocol draws images *above* normal cell text by
36
+ # default (z=0) -- any text this driver writes into the image's row range
37
+ # (the help panel, the periodic-table picker) would otherwise be obscured by
38
+ # the molecule rather than the other way around. A z-index below -2^31/2
39
+ # moves the image under any cell that has an explicit (non-default)
40
+ # background color, i.e. under exactly the overlays this driver draws, while
41
+ # leaving ordinary image-only cells (no background set) unaffected.
42
+ _IMAGE_Z_INDEX = -1_200_000_000
43
+ # How many rows above the status bar (inclusive of it) are a "dead zone" that
44
+ # never forwards clicks to the 3D viewport -- a guard so a click on or just
45
+ # above the element button can't be misread as "click empty space" and birth a
46
+ # stray atom. Bumped above a bare 1-2 rows because those near-button misfires
47
+ # were still slipping through.
48
+ _STATUS_ZONE_ROWS = 4
49
+ # Footer hint shown inside the geometry picker (also sizes its minimum width).
50
+ _GEOM_HINT = " ↑↓ move · Enter/click select · Esc cancel"
51
+ # Fixed visible width of the status bar's left-hand (hover/molecule-info)
52
+ # field -- see _status_bar for why this must be constant, not just capped.
53
+ _LEFT_WIDTH = 24
54
+
55
+ _HELP_HEAD = [
56
+ " vimol — terminal molecular viewer",
57
+ "",
58
+ " Mouse drag ......... rotate Wheel / + - ........ zoom",
59
+ " Right / mid drag ... pan [ / ] .............. roll",
60
+ " Hover .............. identify atom Arrows / h j k l ... rotate",
61
+ " 1 2 3 4 ............ ball / space / licorice / wire",
62
+ ]
63
+ # Shown only when editing is disabled (the classic bindings).
64
+ _HELP_VIEW = [
65
+ " s .................. cycle style a .................. autospin",
66
+ " m .................. measure (click 2/3/4 atoms: distance/angle/dihedral)",
67
+ ]
68
+ # Shown only when editing is enabled (Viewer(editable=True), the vimol CLI default).
69
+ _HELP_EDIT = [
70
+ " a .................. append (edit) o .................. autospin",
71
+ " click H -> grow · heavy atom -> replace · empty space -> new molecule",
72
+ " click the [ C ] pill -> pick a different element to build",
73
+ " x .................. delete s .................. save",
74
+ " u .................. undo",
75
+ " option-drag atom -> atom ... draw a bond (kept beyond auto range)",
76
+ " c .................. cleanup clashes / long bonds",
77
+ " m .................. measure (click 2/3/4 atoms: distance/angle/dihedral)",
78
+ ]
79
+ _HELP_TAIL = [
80
+ " n / p .............. next/prev frame d .................. depth cue",
81
+ " t .................. transparent bg g .................. hi-quality",
82
+ " f / r / z .......... re-fit / reset ? .................. toggle help",
83
+ " q / Esc ............ quit",
84
+ ]
85
+
86
+
87
+ def _help_lines(editable: bool):
88
+ return _HELP_HEAD + (_HELP_EDIT if editable else _HELP_VIEW) + _HELP_TAIL
89
+
90
+ # Keys the driver always claims. 'a' is here in both modes but means different
91
+ # things: autospin when read-only, append when editable (see _driver_key).
92
+ _BASE_DRIVER_KEYS = {"q", "escape", "a", "?", "d", "g", "t", "n", "p", "m", "\x03"}
93
+ # Extra keys claimed only when editing is enabled.
94
+ _EDIT_DRIVER_KEYS = {"s", "u", "o", "x", "c"}
95
+
96
+ # Interactive frame budget (seconds) for the dynamic-resolution controller:
97
+ # ~120 fps, minus headroom for encode/transmit.
98
+ _INTERACT_BUDGET = 1.0 / 120.0
99
+
100
+ # Warm warning color for the status bar's "press c to cleanup" hint.
101
+ _CLEANUP_HINT_FG = (255, 170, 60)
102
+
103
+ # Periodic-table picker panel colors.
104
+ _PT_BG = (18, 20, 26)
105
+ _PT_BORDER_FG = (60, 200, 180) # teal accent, matches the geometry pill
106
+ _PT_TEXT_FG = (220, 220, 230)
107
+ _PT_DIM_FG = (110, 114, 126)
108
+ _PT_GAP_BG = (40, 42, 50)
109
+
110
+
111
+ class Viewer:
112
+ def __init__(self, molecule: Molecule, frames: Optional[List[Molecule]] = None,
113
+ style: Optional[Style] = None, fd_in: int = 0, fd_out: int = 1,
114
+ autospin: bool = False, target_fps: float = 120.0, picking: bool = True,
115
+ transparent: bool = True, backend: str = "auto",
116
+ source_path: Optional[str] = None, editable: bool = False):
117
+ self.frames = frames or [molecule]
118
+ self.source_path = source_path
119
+ self.editable = editable
120
+ for m in self.frames:
121
+ ensure_bonds(m)
122
+ self.frame_index = 0
123
+ self.style = style or Style()
124
+ if style is None:
125
+ # default to a terminal-matching transparent background
126
+ self.style.transparent = transparent
127
+ self.fd_in = fd_in
128
+ self.fd_out = fd_out
129
+ self.autospin = autospin
130
+ self.target_fps = target_fps
131
+
132
+ self.widget = MoleculeWidget(self.frames[0], 320, 240, style=self.style,
133
+ supersample=1, picking=picking, backend=backend,
134
+ editable=editable)
135
+ # Editing keys ('a' append, 's' save, 'u' undo, 'o' autospin) are only
136
+ # bound when editing is enabled; otherwise 'a' keeps its classic meaning
137
+ # (autospin) and 's' falls through to the widget (cycle representation).
138
+ self._driver_keys = set(_BASE_DRIVER_KEYS)
139
+ if editable:
140
+ self._driver_keys |= _EDIT_DRIVER_KEYS
141
+ self.decoder = _input.InputDecoder(pixel=False)
142
+ self._max_ss = 2
143
+ self._drawn_ss = None # supersample of the last frame actually drawn
144
+ # single per-process image id, replaced in place each frame (this is
145
+ # flicker-free in kitty and, unlike a double buffer, never lets a
146
+ # transparent frame ghost the previous one through its cutout).
147
+ self._img_id = kitty.unique_id_base() + 1
148
+
149
+ self._running = False
150
+ self._show_help = False
151
+ # modal state: "normal" | "save_input" | "save_confirm" |
152
+ # "quit_confirm" | "periodic_table" | "geometry_picker"
153
+ self._mode = "normal"
154
+ self._quit_after_save = False # ESC-quit routed through the save prompt
155
+ self._input_buf = ""
156
+ self._msg = "" # transient status message (e.g. "saved foo.xyz")
157
+ # periodic-table picker: cursor position (row, col) into periodic_table.GRID
158
+ self._pt_row, self._pt_col = periodic_table.position_of("C")
159
+ # (row, col_start, col_end) of the clickable element / geometry pills in
160
+ # the last drawn status bar, 0-based cell coords; None when not shown.
161
+ self._elem_button_span = None
162
+ self._geom_button_span = None
163
+ # geometry/hybridization picker: the options list and cursor index
164
+ self._geom_opts: List = []
165
+ self._geom_idx = 0
166
+ # True from a mouse-down that landed in the status-bar zone (see
167
+ # _in_status_zone) until the matching up -- keeps a drag that started
168
+ # on the status bar from ever reaching the 3D viewport, even if the
169
+ # pointer strays back over the molecule mid-drag.
170
+ self._status_zone_press = False
171
+ self._last_interact = 0.0
172
+ self._interact_scale = 1.0 # dynamic-resolution factor while moving
173
+ self._cols = self._rows = 0
174
+ self._img_cols = self._img_rows = 1
175
+ self._cell_px = None # exact (cw, ch) from the terminal, if it answered
176
+ self._old_termios = None
177
+ self._geometry_established = False # True once the real (not placeholder) size is known
178
+ # True while WE own a pushed OSC-22 pointer shape (delete's crosshair,
179
+ # measure's cell). Pushes and pops must pair exactly: an unbalanced pop
180
+ # would clobber a shape pushed by something outside vimol (tmux, the
181
+ # hosting app), and an unbalanced push leaks ours onto their stack.
182
+ self._pointer_pushed = False
183
+
184
+ # -- pointer shape (OSC 22 push/pop stack) -----------------------------
185
+ def _push_pointer(self, shape: str) -> None:
186
+ """Push a pointer *shape*, first popping any shape we already own."""
187
+ if self._pointer_pushed:
188
+ kitty.write_bytes(kitty.reset_pointer_shape(), self.fd_out)
189
+ kitty.write_bytes(kitty.set_pointer_shape(shape), self.fd_out)
190
+ self._pointer_pushed = True
191
+
192
+ def _pop_pointer(self) -> None:
193
+ """Pop our pushed pointer shape; a no-op when we own none."""
194
+ if self._pointer_pushed:
195
+ kitty.write_bytes(kitty.reset_pointer_shape(), self.fd_out)
196
+ self._pointer_pushed = False
197
+
198
+ # -- terminal lifecycle ----------------------------------------------
199
+ def _enter(self):
200
+ import termios
201
+ import tty
202
+ self._old_termios = termios.tcgetattr(self.fd_in) if os.isatty(self.fd_in) else None
203
+ if self._old_termios is not None:
204
+ tty.setraw(self.fd_in)
205
+ kitty.write_bytes(_ALT_SCREEN_ON + _HIDE_CURSOR + _CLEAR, self.fd_out)
206
+ self._setup_mouse(probe=self._old_termios is not None)
207
+
208
+ def _setup_mouse(self, probe: bool) -> None:
209
+ """Enable mouse reporting in a coordinate mode the decoder agrees with.
210
+
211
+ The terminal reports mouse coordinates in *pixels* (SGR-Pixels, DECSET
212
+ 1016) or *cells*, and the decoder must be told which. Historically we
213
+ always requested pixel mode and then set ``decoder.pixel`` from a
214
+ DECRQM probe -- but that probe races a fixed timeout, and over SSH the
215
+ reply routinely arrives late, so the probe said "no pixels" while the
216
+ terminal was already reporting pixels. The decoder then scaled every
217
+ event by the cell size and threw all clicks off-screen: a dead mouse
218
+ that worked fine locally.
219
+
220
+ The fix is to decide ONCE and keep both sides consistent: probe first,
221
+ then enable mouse reporting in exactly the mode the probe confirmed. A
222
+ timed-out probe now falls back to *cell* coordinates on both the wire
223
+ and the decoder -- coarser (vim-level) but fully working -- instead of
224
+ the split that broke it.
225
+ """
226
+ pixel = False
227
+ if probe:
228
+ # ask (in raw mode) for pixel-mouse support and the exact cell size
229
+ # so pixel->cell hit-testing lines up with where glyphs are drawn.
230
+ pixel = _input.supports_pixel_mouse(self.fd_in, self.fd_out)
231
+ self._cell_px = kitty.query_cell_size_px(self.fd_in, self.fd_out)
232
+ self.decoder.pixel = pixel
233
+ kitty.write_bytes(_input.enable_mouse(pixel=pixel, hover=self.widget.picking),
234
+ self.fd_out)
235
+
236
+ def _exit(self):
237
+ import termios
238
+ cleanup = kitty.delete_image(self._img_id)
239
+ # pop our pointer shape iff we pushed one (quit/kill mid-delete or
240
+ # mid-measure must not leave the cursor stuck) -- but never a bare
241
+ # unbalanced pop, which would clobber a shape pushed outside vimol.
242
+ pointer = kitty.reset_pointer_shape() if self._pointer_pushed else b""
243
+ self._pointer_pushed = False
244
+ kitty.write_bytes(_input.disable_mouse(pixel=True) + cleanup
245
+ + pointer
246
+ + _SHOW_CURSOR + _ALT_SCREEN_OFF, self.fd_out)
247
+ if self._old_termios is not None:
248
+ termios.tcsetattr(self.fd_in, termios.TCSADRAIN, self._old_termios)
249
+
250
+ # -- geometry ---------------------------------------------------------
251
+ def _update_geometry(self) -> bool:
252
+ cols, rows, xpx, ypx = kitty.terminal_size_px(self.fd_out)
253
+ # Prefer the terminal's authoritative cell size (queried once at enter);
254
+ # fall back to the window-px/cell-count estimate if it didn't answer.
255
+ cw, ch = self._cell_px or kitty.cell_size_px(self.fd_out)
256
+ changed = (cols, rows) != (self._cols, self._rows)
257
+ self._cols, self._rows = cols, rows
258
+ self.widget.set_cell_metrics(cw, ch)
259
+ if changed:
260
+ self._img_rows = max(rows - 1, 1) # reserve one row for status
261
+ self._img_cols = cols
262
+ w = int(self._img_cols * cw)
263
+ h = int(self._img_rows * ch)
264
+ # The widget was built at a 320x240 placeholder (the real terminal
265
+ # size isn't known until the tty is in raw mode); the first time we
266
+ # learn the real size, fit fresh to it rather than preserving the
267
+ # zoom that was fit for the placeholder. Every later resize (the
268
+ # user actually resizing their terminal) preserves the view as usual.
269
+ self.widget.set_pixel_size(max(w, 16), max(h, 16),
270
+ refit=not self._geometry_established)
271
+ self._geometry_established = True
272
+ return changed
273
+
274
+ # -- rendering --------------------------------------------------------
275
+ def _target_ss(self) -> int:
276
+ """Supersample factor we *want* right now: 1 while interacting (fast),
277
+ higher once settled (crisp). The loop redraws when this changes so the
278
+ crisp frame lands ~0.25s after you stop, without re-drawing meanwhile.
279
+ """
280
+ idle = (time.time() - self._last_interact) > 0.25 and not self.autospin
281
+ return self._max_ss if idle else 1
282
+
283
+ @staticmethod
284
+ def _next_render_scale(current: float, elapsed: float) -> float:
285
+ """Dynamic-resolution controller: the next interactive render scale.
286
+
287
+ Aims the CPU raycaster at the ~120 fps frame budget by trading
288
+ resolution for speed while the camera is moving (pixels scale with
289
+ the square of the factor, so the correction is a square root). Slow
290
+ frames step the scale down immediately; comfortably fast frames ease
291
+ it back up. Clamped to [0.15, 1]; the floor is low enough that even a
292
+ full-screen Retina terminal can reach the budget, is a last resort the
293
+ controller only reaches when the budget demands it, and only ever
294
+ applies during motion (idle frames are always drawn at full resolution
295
+ + supersampling).
296
+ """
297
+ if elapsed > _INTERACT_BUDGET:
298
+ factor = (_INTERACT_BUDGET / elapsed) ** 0.5
299
+ return max(0.15, current * max(factor, 0.5))
300
+ if elapsed < _INTERACT_BUDGET * 0.5:
301
+ return min(1.0, current * 1.15)
302
+ return current
303
+
304
+ def _draw(self):
305
+ want_ss = self._target_ss()
306
+ interacting = want_ss == 1
307
+ scene = self.widget.scene
308
+ if scene.supersample != want_ss:
309
+ scene.set_supersample(want_ss)
310
+ # dynamic resolution (CPU backend only -- GL is fast at full size):
311
+ # render interactive frames at the adaptive scale, idle ones crisp.
312
+ if scene.backend == "cpu":
313
+ scene.set_render_scale(self._interact_scale if interacting else 1.0)
314
+ self._drawn_ss = want_ss
315
+
316
+ # Time the WHOLE frame -- render *and* the encode/transmit that follow.
317
+ # Compressing and base64-ing the image costs about as much as the
318
+ # raycast itself, so a controller that watched render() alone would
319
+ # keep resolution high while encode+write silently blew the budget.
320
+ frame_start = time.perf_counter()
321
+ img = self.widget.render()
322
+ data = kitty.encode_image(img, image_id=self._img_id, placement_id=self._img_id,
323
+ cols=self._img_cols, rows=self._img_rows, move_cursor=False,
324
+ z_index=_IMAGE_Z_INDEX,
325
+ # fast, slightly-larger compression while moving;
326
+ # the resting still gets the smaller default.
327
+ compress_level=1 if interacting else 6)
328
+ out = bytearray()
329
+ out += _HOME + data
330
+ out += b"\x1b[%d;1H\x1b[2K" % self._rows
331
+ out += self._status_bar().encode("utf-8", "replace")
332
+ kitty.write_bytes(bytes(out), self.fd_out)
333
+ if interacting and scene.backend == "cpu":
334
+ self._interact_scale = self._next_render_scale(
335
+ self._interact_scale, time.perf_counter() - frame_start)
336
+ if self._show_help:
337
+ self._draw_help()
338
+ elif self._mode == "periodic_table":
339
+ self._draw_periodic_table()
340
+ elif self._mode == "geometry_picker":
341
+ self._draw_geometry_picker()
342
+
343
+ def _draw_help(self):
344
+ out = bytearray()
345
+ for k, line in enumerate(_help_lines(self.editable)):
346
+ out += b"\x1b[%d;3H" % (2 + k)
347
+ out += b"\x1b[48;2;20;22;30m\x1b[38;2;220;220;230m"
348
+ out += (" " + line.ljust(58)).encode()
349
+ out += b"\x1b[0m"
350
+ kitty.write_bytes(bytes(out), self.fd_out)
351
+
352
+ # -- periodic-table picker ---------------------------------------------
353
+ def _pt_geometry(self) -> Tuple[int, int, int, int]:
354
+ """(top, left, width, height) of the picker panel, 0-based cell coords.
355
+
356
+ Anchored like a dropdown: horizontally centered on the element button
357
+ that opens it, flush against the row just above the status bar --
358
+ not centered on screen, so it opens right where you clicked instead
359
+ of off in the middle of the viewport.
360
+ """
361
+ cell_w = 4
362
+ grid_w = periodic_table.N_COLS * cell_w
363
+ width = grid_w + 4 # 2 borders + 1 pad each side
364
+ height = len(periodic_table.GRID) + 4 # border+grid+info+hint+border
365
+
366
+ if self._elem_button_span is not None:
367
+ _row, col_start, col_end = self._elem_button_span
368
+ anchor_center = (col_start + col_end) // 2
369
+ else:
370
+ anchor_center = self._cols // 2
371
+ left = max(0, min(anchor_center - width // 2, max(self._cols - width, 0)))
372
+ top = max(0, self._rows - 1 - height)
373
+ return top, left, width, height
374
+
375
+ def _pt_cell_at_screen(self, screen_row: int, screen_col: int):
376
+ """The Cell at 0-based screen (row, col), or None if outside the grid."""
377
+ top, left, _width, _height = self._pt_geometry()
378
+ r = screen_row - (top + 1)
379
+ if not (0 <= r < len(periodic_table.GRID)):
380
+ return None
381
+ grid_col_start = left + 2
382
+ if screen_col < grid_col_start:
383
+ return None
384
+ c = (screen_col - grid_col_start) // 4
385
+ if not (0 <= c < periodic_table.N_COLS):
386
+ return None
387
+ return periodic_table.GRID[r][c]
388
+
389
+ @staticmethod
390
+ def _pt_cell_text(cell, cursor: bool) -> str:
391
+ """The 4-char escaped label for one periodic-table cell."""
392
+ if cell is None:
393
+ return " "
394
+ if cell.symbol is None:
395
+ bg, fg = _PT_GAP_BG, _PT_DIM_FG
396
+ label = f"{cell.text:^4}"
397
+ else:
398
+ rgb = elements.element_color(cell.symbol)
399
+ bg = tuple(int(v * 255) for v in rgb)
400
+ lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
401
+ fg = (10, 12, 14) if lum > 140 else (245, 246, 250)
402
+ label = f"{cell.symbol:^4}"
403
+ seg = f"\x1b[48;2;{bg[0]};{bg[1]};{bg[2]}m\x1b[38;2;{fg[0]};{fg[1]};{fg[2]}m"
404
+ if cursor:
405
+ return f"{seg}\x1b[1m\x1b[7m{label}\x1b[27m\x1b[22m\x1b[0m"
406
+ return f"{seg}{label}\x1b[0m"
407
+
408
+ def _draw_periodic_table(self):
409
+ top, left, width, height = self._pt_geometry()
410
+ inner_w = width - 2
411
+ border = (f"\x1b[48;2;{_PT_BG[0]};{_PT_BG[1]};{_PT_BG[2]}m"
412
+ f"\x1b[38;2;{_PT_BORDER_FG[0]};{_PT_BORDER_FG[1]};{_PT_BORDER_FG[2]}m")
413
+ bg_only = f"\x1b[48;2;{_PT_BG[0]};{_PT_BG[1]};{_PT_BG[2]}m"
414
+ text_fg = f"\x1b[38;2;{_PT_TEXT_FG[0]};{_PT_TEXT_FG[1]};{_PT_TEXT_FG[2]}m"
415
+ out = bytearray()
416
+
417
+ def put(row0: int, col0: int, s: str) -> None:
418
+ out.extend(b"\x1b[%d;%dH" % (row0 + 1, col0 + 1))
419
+ out.extend(s.encode("utf-8", "replace"))
420
+
421
+ title = " Pick an element ".center(inner_w, "─")
422
+ put(top, left, f"{border}┌{title}┐\x1b[0m")
423
+
424
+ for r, grow in enumerate(periodic_table.GRID):
425
+ cells = "".join(self._pt_cell_text(c, r == self._pt_row and ci == self._pt_col)
426
+ for ci, c in enumerate(grow))
427
+ put(top + 1 + r, left, f"{border}│ \x1b[0m{bg_only}{cells}\x1b[0m{border} │\x1b[0m")
428
+
429
+ cur = periodic_table.GRID[self._pt_row][self._pt_col]
430
+ if cur is not None and cur.symbol is not None:
431
+ geom = templates.default_template(cur.symbol).geometry
432
+ info = f" {elements.element_name(cur.symbol)} ({cur.symbol}) → {geom}"
433
+ elif cur is not None:
434
+ info = f" {cur.note or ''}"
435
+ else:
436
+ info = ""
437
+ hint = " ↑↓←→ move · Enter/click select · Esc cancel"
438
+ info_row = top + 1 + len(periodic_table.GRID)
439
+ put(info_row, left, f"{border}│\x1b[0m{bg_only}{text_fg}{info.ljust(inner_w)}\x1b[0m{border}│\x1b[0m")
440
+ put(info_row + 1, left, f"{border}│\x1b[0m{bg_only}{text_fg}{hint.ljust(inner_w)}\x1b[0m{border}│\x1b[0m")
441
+ put(info_row + 2, left, f"{border}└{'─' * inner_w}┘\x1b[0m")
442
+
443
+ kitty.write_bytes(bytes(out), self.fd_out)
444
+
445
+ def _open_periodic_table(self) -> None:
446
+ self._mode = "periodic_table"
447
+ self._pt_row, self._pt_col = periodic_table.position_of(self.widget.build_element)
448
+ self._msg = ""
449
+
450
+ def _close_periodic_table(self, pick: Optional[str]) -> None:
451
+ if pick is not None:
452
+ self.widget.build_element = pick
453
+ # a new element resets geometry to that element's default template
454
+ self.widget.build_template = None
455
+ # The overlay painted opaque text cells over the molecule image. Erase
456
+ # only those rows (to the default background, which the negatively
457
+ # z-indexed image shows through) instead of clearing the whole screen
458
+ # -- a full \x1b[2J blanks and repaints everything, which reads as an
459
+ # abrupt full-terminal flash; this just lifts the panel off.
460
+ top, _left, _width, height = self._pt_geometry()
461
+ self._mode = "normal"
462
+ self._erase_rows(top, height)
463
+
464
+ def _erase_rows(self, top: int, count: int) -> None:
465
+ """Erase `count` terminal rows starting at 0-based row `top`.
466
+
467
+ Each row is reset to the default background and cleared, so the
468
+ molecule image (drawn beneath default-background cells) reappears
469
+ there without a whole-screen repaint.
470
+ """
471
+ out = bytearray()
472
+ last = min(top + count, max(self._rows - 1, 0)) # never wipe the status row
473
+ for r in range(max(top, 0), last):
474
+ out += b"\x1b[%d;1H\x1b[0m\x1b[2K" % (r + 1)
475
+ if out:
476
+ kitty.write_bytes(bytes(out), self.fd_out)
477
+
478
+ # -- geometry / hybridization picker ----------------------------------
479
+ def _geom_label_width(self) -> int:
480
+ return max((len(o.label()) for o in self._geom_opts), default=8)
481
+
482
+ def _geom_geometry(self) -> Tuple[int, int, int, int]:
483
+ """(top, left, width, height) of the geometry picker, anchored above
484
+ the geometry pill and flush against the row above the status bar."""
485
+ title_w = len(f" {self.widget.build_element}: geometry ")
486
+ # inner width must fit the widest of: an option row (" ● label "),
487
+ # the title, and the hint -- else that content overruns the border.
488
+ inner = max(self._geom_label_width() + 4, title_w, len(_GEOM_HINT))
489
+ width = inner + 2
490
+ height = len(self._geom_opts) + 3 # top border + rows + hint + bottom
491
+ if self._geom_button_span is not None:
492
+ _row, c0, c1 = self._geom_button_span
493
+ anchor = (c0 + c1) // 2
494
+ else:
495
+ anchor = self._cols // 2
496
+ left = max(0, min(anchor - width // 2, max(self._cols - width, 0)))
497
+ top = max(0, self._rows - 1 - height)
498
+ return top, left, width, height
499
+
500
+ def _geom_row_at_screen(self, screen_row: int, screen_col: int) -> Optional[int]:
501
+ top, left, width, _height = self._geom_geometry()
502
+ if not (left <= screen_col < left + width):
503
+ return None
504
+ i = screen_row - (top + 1)
505
+ if 0 <= i < len(self._geom_opts):
506
+ return i
507
+ return None
508
+
509
+ def _draw_geometry_picker(self):
510
+ top, left, width, height = self._geom_geometry()
511
+ inner_w = width - 2
512
+ border = (f"\x1b[48;2;{_PT_BG[0]};{_PT_BG[1]};{_PT_BG[2]}m"
513
+ f"\x1b[38;2;{_PT_BORDER_FG[0]};{_PT_BORDER_FG[1]};{_PT_BORDER_FG[2]}m")
514
+ bg_only = f"\x1b[48;2;{_PT_BG[0]};{_PT_BG[1]};{_PT_BG[2]}m"
515
+ text_fg = f"\x1b[38;2;{_PT_TEXT_FG[0]};{_PT_TEXT_FG[1]};{_PT_TEXT_FG[2]}m"
516
+ out = bytearray()
517
+
518
+ def put(row0: int, col0: int, s: str) -> None:
519
+ out.extend(b"\x1b[%d;%dH" % (row0 + 1, col0 + 1))
520
+ out.extend(s.encode("utf-8", "replace"))
521
+
522
+ active = self._active_template()
523
+ title = f" {self.widget.build_element}: geometry ".center(inner_w, "─")
524
+ put(top, left, f"{border}┌{title}┐\x1b[0m")
525
+ for i, opt in enumerate(self._geom_opts):
526
+ is_active = (opt.valence == active.valence and opt.geometry == active.geometry)
527
+ # ASCII, not "●": that bullet's East Asian Width is Ambiguous, and a
528
+ # terminal rendering it 2 columns wide misaligns the right border by
529
+ # 1 column (same class of bug as the status-bar ellipsis below).
530
+ marker = "*" if is_active else " "
531
+ label = f" {marker} {opt.label()}".ljust(inner_w)
532
+ if i == self._geom_idx:
533
+ row_s = f"{bg_only}\x1b[1m\x1b[7m{label}\x1b[27m\x1b[22m"
534
+ else:
535
+ row_s = f"{bg_only}{text_fg}{label}"
536
+ put(top + 1 + i, left, f"{border}│\x1b[0m{row_s}\x1b[0m{border}│\x1b[0m")
537
+ hint = _GEOM_HINT
538
+ hint_row = top + 1 + len(self._geom_opts)
539
+ put(hint_row, left, f"{border}│\x1b[0m{bg_only}{text_fg}{hint.ljust(inner_w)}\x1b[0m{border}│\x1b[0m")
540
+ put(hint_row + 1, left, f"{border}└{'─' * inner_w}┘\x1b[0m")
541
+ kitty.write_bytes(bytes(out), self.fd_out)
542
+
543
+ def _open_geometry_picker(self) -> None:
544
+ self._mode = "geometry_picker"
545
+ self._geom_opts = templates.options_for(self.widget.build_element)
546
+ active = self._active_template()
547
+ self._geom_idx = 0
548
+ for i, opt in enumerate(self._geom_opts):
549
+ if opt.valence == active.valence and opt.geometry == active.geometry:
550
+ self._geom_idx = i
551
+ break
552
+ self._msg = ""
553
+
554
+ def _close_geometry_picker(self, pick) -> None:
555
+ if pick is not None:
556
+ self.widget.build_template = pick
557
+ self.widget.build_element = pick.element
558
+ top, _left, _width, height = self._geom_geometry()
559
+ self._mode = "normal"
560
+ self._erase_rows(top, height)
561
+
562
+ def _geom_activate(self) -> None:
563
+ if self._geom_opts:
564
+ self._close_geometry_picker(self._geom_opts[self._geom_idx])
565
+
566
+ def _handle_geom_event(self, ev) -> bool:
567
+ """Drive the geometry picker. Returns True if the display changed."""
568
+ n = len(self._geom_opts)
569
+ if isinstance(ev, _input.KeyEvent):
570
+ key = ev.key
571
+ if key in ("escape", "\x03"):
572
+ self._close_geometry_picker(None); return True
573
+ if key in ("up", "k"):
574
+ self._geom_idx = max(0, self._geom_idx - 1); return True
575
+ if key in ("down", "j"):
576
+ self._geom_idx = min(n - 1, self._geom_idx + 1); return True
577
+ if key == "enter":
578
+ self._geom_activate(); return True
579
+ return False
580
+ if isinstance(ev, _input.MouseEvent):
581
+ if ev.action not in ("down", "move"):
582
+ return False
583
+ col, row = self._event_cell(ev)
584
+ i = self._geom_row_at_screen(row, col)
585
+ if i is None:
586
+ if ev.action == "down":
587
+ # a click outside the list (the pills were already
588
+ # intercepted in _dispatch) closes the picker without
589
+ # picking anything -- same as Escape.
590
+ self._close_geometry_picker(None)
591
+ return True
592
+ return False
593
+ if ev.action == "move":
594
+ if i != self._geom_idx:
595
+ self._geom_idx = i
596
+ return True
597
+ return False
598
+ self._geom_idx = i
599
+ self._geom_activate()
600
+ return True
601
+ return False
602
+
603
+ @staticmethod
604
+ def _pt_nearest_col(row_cells, col: int) -> Optional[int]:
605
+ """The nearest landable column to *col* in *row_cells* (itself if valid)."""
606
+ if row_cells[col] is not None:
607
+ return col
608
+ best = None
609
+ for cc in range(periodic_table.N_COLS):
610
+ if row_cells[cc] is not None and (best is None or abs(cc - col) < abs(best - col)):
611
+ best = cc
612
+ return best
613
+
614
+ def _pt_move(self, dr: int, dc: int) -> None:
615
+ """Move the picker cursor, skipping blank (non-existent) grid cells."""
616
+ row, col = self._pt_row, self._pt_col
617
+ if dc:
618
+ c = col
619
+ while True:
620
+ c += dc
621
+ if not (0 <= c < periodic_table.N_COLS):
622
+ return
623
+ if periodic_table.GRID[row][c] is not None:
624
+ self._pt_col = c
625
+ return
626
+ else:
627
+ r = row
628
+ while True:
629
+ r += dr
630
+ if not (0 <= r < len(periodic_table.GRID)):
631
+ return
632
+ best = self._pt_nearest_col(periodic_table.GRID[r], col)
633
+ if best is not None:
634
+ self._pt_row, self._pt_col = r, best
635
+ return
636
+
637
+ def _pt_activate(self) -> None:
638
+ """Enter/click on the current cursor cell: pick the element, or jump
639
+ to the lanthanide/actinide row if the cursor is on a gap placeholder."""
640
+ cell = periodic_table.GRID[self._pt_row][self._pt_col]
641
+ if cell is None:
642
+ return
643
+ if cell.symbol is not None:
644
+ self._close_periodic_table(pick=cell.symbol)
645
+ elif cell.jump_row is not None:
646
+ target = periodic_table.GRID[cell.jump_row]
647
+ col = self._pt_nearest_col(target, self._pt_col)
648
+ if col is not None:
649
+ self._pt_row, self._pt_col = cell.jump_row, col
650
+
651
+ def _event_cell(self, ev: _input.MouseEvent) -> Tuple[int, int]:
652
+ """A mouse event's position in 0-based (col, row) terminal cells."""
653
+ if ev.pixel:
654
+ cw = self.widget.cell_w or 1.0
655
+ ch = self.widget.cell_h or 1.0
656
+ return int(ev.x // cw), int(ev.y // ch)
657
+ return int(ev.x), int(ev.y)
658
+
659
+ def _in_status_zone(self, row: int) -> bool:
660
+ """True for the status bar's row plus a few rows of margin above it.
661
+
662
+ Every mouse event landing here is kept from ever reaching the 3D
663
+ viewport (see _dispatch) -- not just clicks on the element button's
664
+ exact span -- so a near-miss click can never be misread as "click
665
+ empty space" and birth an atom right under the button. The margin
666
+ (see _STATUS_ZONE_ROWS) also absorbs any off-by-one in the terminal's
667
+ own pixel/cell rounding, so a click that visually looks like it
668
+ landed on the button still registers even if it decodes a row off.
669
+ """
670
+ if self._rows <= 0: # geometry not established yet -- nothing to protect
671
+ return False
672
+ return row >= self._rows - _STATUS_ZONE_ROWS
673
+
674
+ def _handle_pt_event(self, ev) -> bool:
675
+ """Drive the periodic-table picker. Returns True if the display changed."""
676
+ if isinstance(ev, _input.KeyEvent):
677
+ key = ev.key
678
+ if key in ("escape", "\x03"):
679
+ self._close_periodic_table(pick=None)
680
+ return True
681
+ if key in ("up", "k"):
682
+ self._pt_move(-1, 0); return True
683
+ if key in ("down", "j"):
684
+ self._pt_move(1, 0); return True
685
+ if key in ("left", "h"):
686
+ self._pt_move(0, -1); return True
687
+ if key in ("right", "l"):
688
+ self._pt_move(0, 1); return True
689
+ if key == "enter":
690
+ self._pt_activate()
691
+ return True
692
+ return False
693
+ if isinstance(ev, _input.MouseEvent):
694
+ if ev.action not in ("down", "move"):
695
+ return False
696
+ col, row = self._event_cell(ev)
697
+ cell = self._pt_cell_at_screen(row, col)
698
+ if cell is None:
699
+ if ev.action == "down":
700
+ # a click outside the grid (the pills were already
701
+ # intercepted in _dispatch) closes the picker without
702
+ # picking anything -- same as Escape.
703
+ self._close_periodic_table(pick=None)
704
+ return True
705
+ return False
706
+ if ev.action == "move":
707
+ if (cell.row, cell.col) != (self._pt_row, self._pt_col):
708
+ self._pt_row, self._pt_col = cell.row, cell.col
709
+ return True
710
+ return False
711
+ self._pt_row, self._pt_col = cell.row, cell.col
712
+ self._pt_activate()
713
+ return True
714
+ return False
715
+
716
+ @staticmethod
717
+ def _pill(label: str, bg, fg=None) -> str:
718
+ """A padded, bold, reverse-video 'button' -- a clickable-looking pill."""
719
+ r, g, b = (int(c * 255) if c <= 1 else int(c) for c in bg)
720
+ if fg is None:
721
+ lum = 0.299 * r + 0.587 * g + 0.114 * b # pick readable text
722
+ fg = (10, 12, 14) if lum > 140 else (245, 246, 250)
723
+ fr, fg_, fb = fg
724
+ return (f"\x1b[48;2;{r};{g};{b}m\x1b[38;2;{fr};{fg_};{fb}m\x1b[1m"
725
+ f" {label} \x1b[22m\x1b[0m")
726
+
727
+ def _active_template(self):
728
+ """The template a build would use now: the chosen one, else the
729
+ current element's default."""
730
+ return self.widget.build_template or templates.default_template(self.widget.build_element)
731
+
732
+ def _edit_buttons(self) -> Tuple[str, int, Tuple[int, int], Tuple[int, int]]:
733
+ """'adding [ C ] [ tetrahedral ]' with each token as a colored button.
734
+
735
+ Clicking the element pill opens the periodic-table picker; clicking the
736
+ geometry pill opens the geometry/hybridization picker for that element
737
+ (see _open_periodic_table / _open_geometry_picker). Returns the escaped
738
+ text, its own total visible width, and the (start, end) visible-column
739
+ spans of the element pill and the geometry pill, each relative to this
740
+ text's own start -- so callers can locate both clickable buttons
741
+ without re-deriving _pill's layout by hand.
742
+ """
743
+ elem = self.widget.build_element
744
+ geom = self._active_template().geometry
745
+ prefix = "adding "
746
+ elem_visible = f" {elem} "
747
+ geom_visible = f" {geom} "
748
+ elem_btn = self._pill(elem, elements.element_color(elem))
749
+ geom_btn = self._pill(geom, (0.17, 0.71, 0.63)) # teal accent
750
+ text = f"\x1b[38;2;150;155;170m{prefix}\x1b[0m{elem_btn} {geom_btn}"
751
+ elem_start = len(prefix)
752
+ elem_end = elem_start + len(elem_visible)
753
+ geom_start = elem_end + 1 # +1 for the space between the pills
754
+ geom_end = geom_start + len(geom_visible)
755
+ return text, geom_end, (elem_start, elem_end), (geom_start, geom_end)
756
+
757
+ @staticmethod
758
+ def _build_segment(pieces) -> Tuple[str, int, List[int]]:
759
+ """Join (escaped_text, visible_len) pieces into one string.
760
+
761
+ Returns the joined escaped text, its total visible length, and each
762
+ piece's visible-column offset from the segment's own start -- so a
763
+ caller can locate a button embedded in one of the pieces without
764
+ re-deriving the layout of everything drawn before it.
765
+ """
766
+ parts = []
767
+ offsets = []
768
+ total = 0
769
+ for escaped, vis_len in pieces:
770
+ offsets.append(total)
771
+ parts.append(escaped)
772
+ total += vis_len
773
+ return "".join(parts), total, offsets
774
+
775
+ def _status_bar(self) -> str:
776
+ self._elem_button_span = None
777
+ self._geom_button_span = None
778
+ if self._mode == "save_input":
779
+ body = f" Save to: {self._input_buf}█ Enter save · Esc cancel "
780
+ return f"\x1b[48;2;44;40;30m\x1b[38;2;240;236;220m{body}\x1b[0m"
781
+ if self._mode == "save_confirm":
782
+ name = os.path.basename(self._input_buf.strip())
783
+ body = f" {name} exists — replace? (y/n) "
784
+ return f"\x1b[48;2;60;30;30m\x1b[38;2;250;230;230m{body}\x1b[0m"
785
+ if self._mode == "quit_confirm":
786
+ body = " unsaved changes — save before quitting? (y/n/Esc) "
787
+ return f"\x1b[48;2;60;30;30m\x1b[38;2;250;230;230m{body}\x1b[0m"
788
+ mol = self.widget.molecule
789
+ hov = self.widget.atom_info(self.widget.hovered)
790
+ # a live measurement readout (2+ picks in measure mode) outranks the
791
+ # hover text; with 0-1 picks measurement() is "" and the normal
792
+ # left-segment behavior applies.
793
+ measure = (editor.measurement(mol, self.widget.measure_sel)
794
+ if self.widget.measure_mode else "")
795
+ raw_left = measure or hov or (self._msg or
796
+ f"{(mol.name or 'molecule')[:22]} {mol.formula()} {mol.n_atoms} atoms")
797
+ # Fixed-width, not just truncated: hover text changes on every mouse
798
+ # move and can be shorter *or* longer than the previous frame's, so
799
+ # only a constant width (never just a cap) keeps the trailer -- and
800
+ # the clickable element button in it -- from drifting sideways as
801
+ # the cursor merely moves around the molecule.
802
+ #
803
+ # The truncation marker MUST be a single-column-guaranteed ASCII
804
+ # character, not the Unicode ellipsis "…" (U+2026): its East Asian
805
+ # Width is "Ambiguous", and terminals/fonts that render it 2 columns
806
+ # wide push this line 1 column past _LEFT_WIDTH on the LAST row of
807
+ # the screen. With no row below to wrap into, that overflow scrolls
808
+ # the whole terminal up instead -- and since hover text is stable
809
+ # while the mouse sits still, every subsequent redraw (each edit
810
+ # forces one) re-triggers it, which is what actually produced the
811
+ # "status line duplicates on every edit" bug: not a second draw, but
812
+ # the same line scrolling up and leaving its old position visible.
813
+ left = (raw_left[:_LEFT_WIDTH - 1] + ">") if len(raw_left) > _LEFT_WIDTH else raw_left.ljust(_LEFT_WIDTH)
814
+ rep = self.style.representation
815
+ frame = f" {self.frame_index+1}/{len(self.frames)}" if len(self.frames) > 1 else ""
816
+ spin = " ⟳" if self.autospin else ""
817
+ px = " px" if self.decoder.pixel else ""
818
+ backend = "gpu" if self.widget.scene.backend == "gl" else "cpu"
819
+ base = "\x1b[48;2;30;33;44m\x1b[38;2;230;232;240m"
820
+ mod = " [MODIFIED]" if (self.editable and self.widget.dirty) else ""
821
+ hint = " s save q quit" if self.editable else " q quit"
822
+ show_buttons = self.editable and self.widget.append_mode
823
+ show_delete = self.editable and self.widget.delete_mode
824
+ show_measure = self.widget.measure_mode # read-only-safe: no editable gate
825
+
826
+ # Everything from the representation tag onward is a "trailer" built
827
+ # from (escaped, visible_len) pieces and right-anchored via padding
828
+ # below -- independent of `left`'s length. `left` carries hover text
829
+ # that changes on every mouse move; if the clickable element button
830
+ # merely followed it in one concatenated string, moving the mouse
831
+ # (with no click at all) would shift the button out from under the
832
+ # cursor before the next click landed.
833
+ pieces = [(f"[{rep}]", len(rep) + 2), (frame, len(frame)), (spin, len(spin))]
834
+ if show_buttons:
835
+ pieces.append((f" {base}\x1b[1m✎APPEND\x1b[22m", 9)) # " ✎APPEND"
836
+ elif show_delete:
837
+ pieces.append((f" {base}\x1b[1m✗DELETE\x1b[22m", 9)) # " ✗DELETE"
838
+ elif show_measure:
839
+ pieces.append((f" {base}\x1b[1m∡MEASURE\x1b[22m", 10)) # " ∡MEASURE"
840
+ pieces.append((mod, len(mod)))
841
+ # Cleanup hint: recomputed from model state every render (no hover
842
+ # dependence), so it appears/disappears exactly like [MODIFIED] does
843
+ # and never disturbs the button-span stability tests.
844
+ cleanup_hint = ""
845
+ if self.editable:
846
+ clash, stretched = editor.cleanup_targets(mol)
847
+ if clash or stretched:
848
+ r, g, b = _CLEANUP_HINT_FG
849
+ cleanup_hint = f" \x1b[38;2;{r};{g};{b}m\x1b[1m⚠ c cleanup\x1b[22m{base}"
850
+ cleanup_hint_len = len(" ⚠ c cleanup") if cleanup_hint else 0
851
+ pieces.append((cleanup_hint, cleanup_hint_len))
852
+ elem_piece_idx = None
853
+ if show_buttons:
854
+ buttons_text, buttons_len, elem_rel, geom_rel = self._edit_buttons()
855
+ elem_piece_idx = len(pieces)
856
+ pieces.append((f" {buttons_text}{base}", 2 + buttons_len))
857
+ q_text = f" q{self.widget.scene.supersample}x{px}"
858
+ pieces.append((q_text, len(q_text)))
859
+ backend_text = f" [{backend}]"
860
+ pieces.append((backend_text, len(backend_text)))
861
+ help_text = f" ? help{hint}"
862
+ pieces.append((help_text, len(help_text)))
863
+ pieces.append((" ", 1))
864
+
865
+ trailer, trailer_len, offsets = self._build_segment(pieces)
866
+ left_len = 1 + _LEFT_WIDTH # leading space; `base` itself is 0-width
867
+ pad = max(self._cols - left_len - trailer_len, 0)
868
+ if show_buttons:
869
+ base_col = left_len + pad + offsets[elem_piece_idx] + 2
870
+ self._elem_button_span = (self._rows - 1, base_col + elem_rel[0], base_col + elem_rel[1])
871
+ self._geom_button_span = (self._rows - 1, base_col + geom_rel[0], base_col + geom_rel[1])
872
+
873
+ seg = f"{base} {left}{' ' * pad}{trailer}"
874
+ return seg + "\x1b[0m"
875
+
876
+ # -- input ------------------------------------------------------------
877
+ def _read(self, timeout: float) -> bytes:
878
+ r, _, _ = select.select([self.fd_in], [], [], timeout)
879
+ if not r:
880
+ return b""
881
+ try:
882
+ return os.read(self.fd_in, 4096)
883
+ except OSError:
884
+ return b""
885
+
886
+ def _input_pending(self) -> bool:
887
+ """True if input is already waiting to be read (non-blocking peek)."""
888
+ try:
889
+ r, _, _ = select.select([self.fd_in], [], [], 0)
890
+ return bool(r)
891
+ except (OSError, ValueError):
892
+ return False
893
+
894
+ def _span_hit(self, span, col: int, row: int) -> bool:
895
+ """True if (col, row) lands on *span*, with the same one-row tolerance
896
+ _in_status_zone gives the rest of that zone."""
897
+ if span is None or not self._in_status_zone(row):
898
+ return False
899
+ _btn_row, col_start, col_end = span
900
+ return col_start <= col < col_end
901
+
902
+ def _dispatch(self, events) -> bool:
903
+ """Apply input events; return True if anything visible changed and the
904
+ frame should be redrawn."""
905
+ changed = False
906
+ for ev in events:
907
+ if self._mode in ("periodic_table", "geometry_picker"):
908
+ # Clicking the pill that opened the current picker closes it
909
+ # again -- a normal toggle button, not a one-way switch.
910
+ # Clicking the OTHER pill instead switches straight to it:
911
+ # close whichever is open, open the one just clicked.
912
+ if isinstance(ev, _input.MouseEvent) and ev.action == "down":
913
+ col, row = self._event_cell(ev)
914
+ if self._span_hit(self._elem_button_span, col, row):
915
+ was_pt = self._mode == "periodic_table"
916
+ if was_pt:
917
+ self._close_periodic_table(pick=None)
918
+ else:
919
+ self._close_geometry_picker(None)
920
+ self._open_periodic_table()
921
+ changed = True
922
+ continue
923
+ if self._span_hit(self._geom_button_span, col, row):
924
+ was_geom = self._mode == "geometry_picker"
925
+ if was_geom:
926
+ self._close_geometry_picker(None)
927
+ else:
928
+ self._close_periodic_table(pick=None)
929
+ self._open_geometry_picker()
930
+ changed = True
931
+ continue
932
+ handler = (self._handle_pt_event if self._mode == "periodic_table"
933
+ else self._handle_geom_event)
934
+ if handler(ev):
935
+ changed = True
936
+ continue
937
+ if self._mode != "normal":
938
+ # While the save prompt is up, keystrokes drive it and mouse
939
+ # events are swallowed (no accidental rotate mid-save).
940
+ if isinstance(ev, _input.KeyEvent) and self._handle_prompt_key(ev.key):
941
+ changed = True
942
+ continue
943
+ if isinstance(ev, _input.MouseEvent):
944
+ if ev.action == "down":
945
+ col, row = self._event_cell(ev)
946
+ self._status_zone_press = self._in_status_zone(row)
947
+ if self._status_zone_press:
948
+ # A click on the element pill opens the periodic table,
949
+ # on the geometry pill opens the geometry picker; any
950
+ # other click in this zone (a near-miss, or elsewhere on
951
+ # the status bar) is swallowed -- never forwarded to the
952
+ # 3D viewport.
953
+ if self._span_hit(self._elem_button_span, col, row):
954
+ self._open_periodic_table()
955
+ changed = True
956
+ elif self._span_hit(self._geom_button_span, col, row):
957
+ self._open_geometry_picker()
958
+ changed = True
959
+ continue
960
+ elif ev.action in ("drag", "up"):
961
+ if self._status_zone_press:
962
+ if ev.action == "up":
963
+ self._status_zone_press = False
964
+ continue
965
+ elif ev.action in ("move", "scroll"):
966
+ _col, row = self._event_cell(ev)
967
+ if self._in_status_zone(row):
968
+ continue
969
+ if isinstance(ev, _input.KeyEvent) and ev.key in self._driver_keys:
970
+ if self._driver_key(ev.key):
971
+ changed = True
972
+ else:
973
+ if self.widget.handle_event(ev):
974
+ self._last_interact = time.time()
975
+ self._msg = "" # a fresh interaction clears "saved …"
976
+ changed = True
977
+ return changed
978
+
979
+ def _driver_key(self, key: str) -> bool:
980
+ """Handle a driver-level key; return True if it changed the view."""
981
+ if key == "escape" and self.editable and self.widget.dirty:
982
+ # Unsaved changes: ask before quitting. 'q' and Ctrl-C stay
983
+ # immediate quits (deliberate force-quit / emergency paths).
984
+ self._mode = "quit_confirm"
985
+ return True
986
+ elif key in ("q", "escape", "\x03"):
987
+ self._running = False
988
+ return False
989
+ elif key == "a":
990
+ if self.editable:
991
+ self.widget.set_append_mode(not self.widget.append_mode)
992
+ self._msg = ""
993
+ # append owns no pointer shape: switching here from delete or
994
+ # measure must drop theirs so it doesn't linger (no-op if
995
+ # nothing is pushed).
996
+ self._pop_pointer()
997
+ else:
998
+ self.autospin = not self.autospin # classic binding
999
+ elif key == "m":
1000
+ # measuring is read-only-safe, so 'm' works without --edit too
1001
+ self.widget.set_measure_mode(not self.widget.measure_mode)
1002
+ self._msg = ""
1003
+ if self.widget.measure_mode:
1004
+ self._push_pointer("cell") # precision plus-cross
1005
+ else:
1006
+ self._pop_pointer()
1007
+ elif key == "x" and self.editable:
1008
+ self.widget.set_delete_mode(not self.widget.delete_mode)
1009
+ self._msg = ""
1010
+ if self.widget.delete_mode:
1011
+ self._push_pointer("crosshair")
1012
+ else:
1013
+ self._pop_pointer()
1014
+ elif key == "o" and self.editable:
1015
+ self.autospin = not self.autospin # relocated while editing
1016
+ elif key == "s" and self.editable:
1017
+ self._open_save_prompt()
1018
+ elif key == "u" and self.editable:
1019
+ return self.widget.undo()
1020
+ elif key == "c" and self.editable:
1021
+ # starts the animation; the run loop ticks it frame by frame
1022
+ return self.widget.start_cleanup()
1023
+ elif key == "?":
1024
+ self._show_help = not self._show_help
1025
+ if not self._show_help:
1026
+ kitty.write_bytes(_CLEAR, self.fd_out)
1027
+ elif key == "d":
1028
+ self.style.depth_cue = 0.0 if self.style.depth_cue > 0 else 0.55
1029
+ self._last_interact = time.time()
1030
+ elif key == "g":
1031
+ self._max_ss = 3 if self._max_ss == 2 else 2
1032
+ self._last_interact = time.time()
1033
+ elif key == "t":
1034
+ self.style.transparent = not self.style.transparent
1035
+ kitty.write_bytes(_CLEAR, self.fd_out)
1036
+ self._last_interact = time.time()
1037
+ elif key in ("n", "p") and len(self.frames) > 1:
1038
+ self.frame_index = (self.frame_index + (1 if key == "n" else -1)) % len(self.frames)
1039
+ self.widget.set_molecule(self.frames[self.frame_index])
1040
+ self._last_interact = time.time()
1041
+ else:
1042
+ return False
1043
+ return True
1044
+
1045
+ # -- save prompt ------------------------------------------------------
1046
+ def _default_save_path(self) -> str:
1047
+ if self.source_path:
1048
+ return self.source_path
1049
+ name = (self.widget.molecule.name or "molecule").strip() or "molecule"
1050
+ return f"{name}.xyz"
1051
+
1052
+ def _open_save_prompt(self) -> None:
1053
+ self._mode = "save_input"
1054
+ self._input_buf = self._default_save_path()
1055
+ self._msg = ""
1056
+
1057
+ def _handle_prompt_key(self, key: str) -> bool:
1058
+ """Drive the modal save prompt. Returns True if the display changed."""
1059
+ if self._mode == "save_input":
1060
+ if key == "enter":
1061
+ path = self._input_buf.strip()
1062
+ if not path:
1063
+ return False
1064
+ if os.path.exists(path):
1065
+ self._mode = "save_confirm" # ask before clobbering
1066
+ else:
1067
+ self._do_save(path)
1068
+ return True
1069
+ if key in ("escape", "\x03"):
1070
+ self._mode = "normal"
1071
+ self._msg = ""
1072
+ # bailing out of the filename prompt cancels a pending
1073
+ # quit-after-save entirely: a cancelled save must never fall
1074
+ # through to "quit anyway, discarding changes".
1075
+ self._quit_after_save = False
1076
+ return True
1077
+ if key == "backspace":
1078
+ self._input_buf = self._input_buf[:-1]
1079
+ return True
1080
+ if len(key) == 1 and key.isprintable():
1081
+ self._input_buf += key
1082
+ return True
1083
+ return False
1084
+ if self._mode == "save_confirm":
1085
+ if key in ("y", "Y", "enter"):
1086
+ self._do_save(self._input_buf.strip())
1087
+ return True
1088
+ if key in ("n", "N", "escape", "\x03"):
1089
+ self._mode = "save_input" # back to editing the name
1090
+ return True
1091
+ return False
1092
+ if self._mode == "quit_confirm":
1093
+ if key in ("y", "Y", "enter"):
1094
+ self._quit_after_save = True # a successful save then quits
1095
+ self._open_save_prompt()
1096
+ return True
1097
+ if key in ("n", "N"):
1098
+ self._running = False # quit without saving
1099
+ return True
1100
+ if key in ("escape", "\x03"):
1101
+ self._mode = "normal" # cancel the quit, keep working
1102
+ return True
1103
+ return False
1104
+ return False
1105
+
1106
+ def _do_save(self, path: str) -> None:
1107
+ from .parsers import save
1108
+ try:
1109
+ save(self.widget.molecule, path)
1110
+ except (OSError, ValueError) as e:
1111
+ self._msg = f"save failed: {e}"
1112
+ self._quit_after_save = False # a failed save stays running
1113
+ else:
1114
+ self.source_path = path
1115
+ self.widget.mark_saved()
1116
+ self._msg = f"saved {os.path.basename(path)}"
1117
+ if self._quit_after_save: # ESC-quit routed through save
1118
+ self._quit_after_save = False
1119
+ self._running = False
1120
+ self._mode = "normal"
1121
+
1122
+ # -- main loop --------------------------------------------------------
1123
+ def run(self):
1124
+ if not os.isatty(self.fd_out):
1125
+ raise RuntimeError("vimol.Viewer requires a terminal on stdout")
1126
+ self._enter()
1127
+ self._running = True
1128
+ try:
1129
+ self._update_geometry()
1130
+ self._draw()
1131
+ frame_dt = 1.0 / self.target_fps
1132
+ while self._running:
1133
+ data = self._read(frame_dt)
1134
+ changed = self._dispatch(self.decoder.feed(data) if data
1135
+ else self.decoder.flush())
1136
+ if self._update_geometry():
1137
+ kitty.write_bytes(_CLEAR, self.fd_out)
1138
+ changed = True
1139
+ if self.autospin:
1140
+ self.widget.scene.camera.orbit(1.4, 0) # ~0.014 rad/frame
1141
+ self._last_interact = time.time()
1142
+ changed = True
1143
+ if self.widget.cleanup_active:
1144
+ # animate the 'c' relaxation: one tick per frame, kept in
1145
+ # fast (non-supersampled) mode while the atoms settle.
1146
+ self.widget.cleanup_tick()
1147
+ self._last_interact = time.time()
1148
+ changed = True
1149
+ if changed:
1150
+ self._draw()
1151
+ elif self._target_ss() != self._drawn_ss and not self._input_pending():
1152
+ # Settle to a crisp, supersampled frame once the view stops
1153
+ # moving -- but ONLY in a genuine lull with nothing queued.
1154
+ # The high-quality downsample is a heavy synchronous step
1155
+ # (~0.2s at full screen); running it while a keypress or
1156
+ # mouse-move is waiting would stall that input behind it.
1157
+ self._draw()
1158
+ finally:
1159
+ self._exit()