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