molvector 1.0.4__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.
molvector/gui.py ADDED
@@ -0,0 +1,4834 @@
1
+ """
2
+ molvector_gui.py — Interactive 3D Molecule Viewer
3
+ ==============================================
4
+ PyQt6 GUI with full menu bar, appearance controls, and atom colour editor.
5
+
6
+ Default controls:
7
+ Left-drag Rotate molecule
8
+ Right-drag Pan
9
+ Scroll Zoom
10
+
11
+ Build mode (B):
12
+ Left-click Add atom
13
+ Drag Create bond
14
+ Click bond Change bond order
15
+
16
+ Selection mode (S):
17
+ Click Select atom
18
+ Drag Rectangle-select
19
+
20
+ Align mode (A):
21
+ Click bond Align molecule vertically
22
+ Shift+click bond Align molecule horizontally
23
+
24
+ Menus:
25
+ File Open / Save As / Export SVG / Export View / Quick SVG Export / Quit
26
+ Edit Settings / Style / Shortcuts / Info / Edit Charge / InChI / Selection Mode / Build Mode / Align Mode
27
+ View Reset View / Preset orientations / Overlay Settings
28
+ Calculations Generate G16 Input / Calculate Rotational Constants / Calculate Dipole Moment / Calculation Results
29
+ Build Clean / Undo / Redo / Optimize / Disable Bond Order
30
+ Help Open Test Molecule / About
31
+
32
+ Note: On Windows/Linux use Ctrl for shortcuts; on macOS use Cmd.
33
+
34
+ Dependencies:
35
+ pip install PyQt6 numpy svgwrite
36
+ """
37
+
38
+ import sys, os, math, json, tempfile, platform
39
+ from typing import List, Tuple, Optional, Dict
40
+ import numpy as np
41
+
42
+ from PyQt6.QtWidgets import (
43
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
44
+ QLabel, QFileDialog, QSlider, QStatusBar, QFrame, QSizePolicy,
45
+ QGroupBox, QMessageBox, QDialog, QDialogButtonBox, QFormLayout,
46
+ QSpinBox, QDoubleSpinBox, QColorDialog, QPushButton, QGridLayout,
47
+ QScrollArea, QToolBar, QMenu, QCheckBox, QTableWidget, QTableWidgetItem,
48
+ QHeaderView, QTabWidget, QComboBox, QPlainTextEdit, QLineEdit,
49
+ QButtonGroup, QRadioButton, QKeySequenceEdit, QToolButton,
50
+ )
51
+ from PyQt6.QtSvgWidgets import QSvgWidget
52
+ from PyQt6.QtSvg import QSvgRenderer
53
+ from PyQt6.QtCore import Qt, QByteArray, QPoint, QPointF, pyqtSignal, QTimer, QSize, QRect, QRectF, QUrl, QMimeData, QEvent
54
+ from PyQt6.QtGui import QAction, QActionGroup, QColor, QPalette, QFont, QCursor, QIcon, QPixmap, QImage, QPainter, QPdfWriter, QPageSize, QKeySequence, QDesktopServices
55
+ from PyQt6.QtGui import QDrag
56
+
57
+ try:
58
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
59
+ from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
60
+ from matplotlib.figure import Figure
61
+ HAS_MPL = True
62
+ except ImportError:
63
+ HAS_MPL = False
64
+
65
+ # ── renderer / parsers ───────────────────────────────────────────────────────
66
+ from molvector.render import (
67
+ parse_xyz, parse_gaussian, parse_gaussian_log, parse_pdb, parse_mol,
68
+ infer_bonds,
69
+ render_molecule, Molecule, CPK_BASE, CPK_DARK, VDW_RADII,
70
+ lighten, darken, hex_to_rgb, rgb_to_hex, auto_dark,
71
+ chemical_formula, molecular_mass, VibrationalMode, ExcitedState,
72
+ save_xyz, save_gaussian_input, save_pdb, save_mol, project_molecule,
73
+ Atom, Bond,
74
+ optimize_geometry, HAS_OPENBABEL, calculate_rotational_constants, calculate_dipole_moment, generate_inchi, check_valence_issues,
75
+ )
76
+
77
+ # ── shortcut modifier prefix ─────────────────────────────────────────────────
78
+ def _mod(s: str) -> str:
79
+ """Qt already maps 'Ctrl' → ⌘ on macOS; pass through unchanged."""
80
+ return s
81
+
82
+
83
+ def load_colored_icon(svg_path: str, color: str, size: int = 22) -> QIcon:
84
+ """Load an SVG file, replace #000000 fills with color, return QIcon."""
85
+ with open(svg_path, "r", encoding="utf-8") as f:
86
+ svg_content = f.read()
87
+ svg_content = svg_content.replace('#000000', color)
88
+ renderer = QSvgRenderer(QByteArray(svg_content.encode("utf-8")))
89
+ default_size = renderer.defaultSize()
90
+ if default_size.isValid() and default_size.width() > 0 and default_size.height() > 0:
91
+ aspect = default_size.width() / default_size.height()
92
+ if aspect >= 1.0:
93
+ w, h = size, int(size / aspect)
94
+ else:
95
+ w, h = int(size * aspect), size
96
+ else:
97
+ w = h = size
98
+ pix = QPixmap(size, size)
99
+ pix.fill(Qt.GlobalColor.transparent)
100
+ painter = QPainter(pix)
101
+ x = (size - w) // 2
102
+ y = (size - h) // 2
103
+ renderer.render(painter, QRectF(x, y, w, h))
104
+ painter.end()
105
+ return QIcon(pix)
106
+
107
+ def set_icons(app, win):
108
+ system = platform.system()
109
+ if system == "Darwin":
110
+ icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_macos.png")
111
+ elif system == "Linux":
112
+ icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_linux.png")
113
+ elif system == "Windows":
114
+ icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_windows.png")
115
+ else:
116
+ icon_path = os.path.join(os.path.dirname(__file__), "assets", "icons", "icon.svg")
117
+
118
+ if not os.path.isfile(icon_path):
119
+ return
120
+
121
+ icon = QIcon(icon_path)
122
+ app.setWindowIcon(icon)
123
+ win.setWindowIcon(icon)
124
+
125
+ def get_safe_filename(name: str) -> str:
126
+ """C60+ -> C60p, removes special characters."""
127
+ s = name.replace("+", "p").replace("-", "m")
128
+ import re
129
+ return re.sub(r'[^a-zA-Z0-9_-]', '', s)
130
+
131
+
132
+ # ─────────────────────────────────────────────────────────────────────────────
133
+ # CONSTANTS
134
+ # ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ ELEM_FULL_NAME = {
137
+ "H":"Hydrogen", "D":"Deuterium (2H)", "T":"Tritium (3H)", "He":"Helium", "Li":"Lithium", "Be":"Beryllium","B":"Boron",
138
+ "C":"Carbon", "13C":"Carbon-13", "14C":"Carbon-14", "N":"Nitrogen", "15N":"Nitrogen-15", "O":"Oxygen", "F":"Fluorine", "Ne":"Neon",
139
+ "Na":"Sodium", "Mg":"Magnesium","Al":"Aluminium","Si":"Silicon", "P":"Phosphorus",
140
+ "S":"Sulfur", "Cl":"Chlorine", "Ar":"Argon", "K":"Potassium", "Ca":"Calcium",
141
+ "Sc":"Scandium","Ti":"Titanium", "V":"Vanadium", "Cr":"Chromium", "Mn":"Manganese",
142
+ "Fe":"Iron", "Co":"Cobalt", "Ni":"Nickel", "Cu":"Copper", "Zn":"Zinc",
143
+ "Ga":"Gallium", "Ge":"Germanium","As":"Arsenic", "Se":"Selenium", "Br":"Bromine",
144
+ "Kr":"Krypton", "Rb":"Rubidium", "Sr":"Strontium","Y":"Yttrium", "Zr":"Zirconium",
145
+ "Nb":"Niobium", "Mo":"Molybdenum","Tc":"Technetium","Ru":"Ruthenium","Rh":"Rhodium",
146
+ "Pd":"Palladium","Ag":"Silver", "Cd":"Cadmium", "In":"Indium", "Sn":"Tin",
147
+ "Sb":"Antimony", "Te":"Tellurium","I":"Iodine", "Xe":"Xenon", "Cs":"Caesium",
148
+ "Ba":"Barium", "La":"Lanthanum", "Ce":"Cerium", "Pr":"Praseodymium","Nd":"Neodymium",
149
+ "Pm":"Promethium","Sm":"Samarium", "Eu":"Europium", "Gd":"Gadolinium","Tb":"Terbium",
150
+ "Dy":"Dysprosium","Ho":"Holmium", "Er":"Erbium", "Tm":"Thulium", "Yb":"Ytterbium",
151
+ "Lu":"Lutetium", "Hf":"Hafnium", "Ta":"Tantalum", "W":"Tungsten", "Re":"Rhenium",
152
+ "Os":"Osmium", "Ir":"Iridium", "Pt":"Platinum", "Au":"Gold", "Hg":"Mercury",
153
+ "Tl":"Thallium", "Pb":"Lead", "Bi":"Bismuth", "Po":"Polonium", "At":"Astatine",
154
+ "Rn":"Radon", "Fr":"Francium", "Ra":"Radium",
155
+ "Ac":"Actinium","Th":"Thorium", "Pa":"Protactinium","U":"Uranium",
156
+ "Np":"Neptunium","Pu":"Plutonium","Am":"Americium","Cm":"Curium",
157
+ "Bk":"Berkelium","Cf":"Californium","Es":"Einsteinium","Fm":"Fermium",
158
+ "Md":"Mendelevium","No":"Nobelium","Lr":"Lawrencium",
159
+ "Rf":"Rutherfordium","Db":"Dubnium","Sg":"Seaborgium","Bh":"Bohrium",
160
+ "Hs":"Hassium","Mt":"Meitnerium","Ds":"Darmstadtium","Rg":"Roentgenium",
161
+ "Cn":"Copernicium","Nh":"Nihonium","Fl":"Flerovium","Mc":"Moscovium",
162
+ "Lv":"Livermorium","Ts":"Tennessine","Og":"Oganesson",
163
+ }
164
+
165
+ # Theme Colors
166
+ THEMES = {
167
+ "dark": {
168
+ "DARK_BG": "#0f0f1a",
169
+ "PANEL_BG": "#0d0d18",
170
+ "CARD_BG": "#13131f",
171
+ "BORDER": "#2a2a44",
172
+ "FG": "#ccd6f6",
173
+ "FG_DIM": "#99aacc",
174
+ "ACCENT": "#4488cc",
175
+ "CANVAS": "#0a0a12"
176
+ },
177
+ "light": {
178
+ "DARK_BG": "#f0f2f5",
179
+ "PANEL_BG": "#e4e6eb",
180
+ "CARD_BG": "#ffffff",
181
+ "BORDER": "#ced4da",
182
+ "FG": "#1c1e21",
183
+ "FG_DIM": "#65676b",
184
+ "ACCENT": "#007bff",
185
+ "CANVAS": "#ffffff"
186
+ }
187
+ }
188
+
189
+ def get_stylesheet(theme_name: str) -> str:
190
+ t = THEMES[theme_name]
191
+ return f"""
192
+ QMainWindow, QDialog {{ background:{t['DARK_BG']}; }}
193
+ QWidget {{ color:{t['FG']}; }}
194
+ QMenuBar {{
195
+ background:{t['CARD_BG']}; color:{t['FG']};
196
+ border-bottom:1px solid {t['BORDER']}; padding:2px 4px;
197
+ }}
198
+ QMenuBar::item:selected {{ background:{t['BORDER']}; border-radius:3px; }}
199
+ QMenu {{
200
+ background:{t['CARD_BG']}; color:{t['FG']};
201
+ border:1px solid {t['BORDER']}; border-radius:6px;
202
+ padding:4px;
203
+ }}
204
+ QMenu::item {{ padding:5px 24px 5px 10px; border-radius:4px; }}
205
+ QMenu::item:selected {{ background:{t['ACCENT']}; color:#fff; }}
206
+ QMenu::separator {{ height:1px; background:{t['BORDER']}; margin:3px 6px; }}
207
+ QToolBar {{
208
+ background:{t['CARD_BG']}; border-bottom:1px solid {t['BORDER']};
209
+ spacing:4px; padding:3px 8px;
210
+ }}
211
+ QToolBar QToolButton {{
212
+ background:transparent; color:{t['FG']};
213
+ border:1px solid transparent; border-radius:4px; padding:4px 8px;
214
+ }}
215
+ QToolBar QToolButton:hover {{ background:{t['BORDER']}; }}
216
+ QToolBar QToolButton:checked {{
217
+ background:{t['ACCENT']}; color:white;
218
+ border:1px solid {t['ACCENT']};
219
+ }}
220
+ QPushButton {{
221
+ background:{t['CARD_BG']}; color:{t['FG']};
222
+ border:1px solid {t['BORDER']}; border-radius:5px;
223
+ padding:5px 14px; font-size:12px;
224
+ }}
225
+ QPushButton:hover {{ background:{t['BORDER']}; border-color:{t['ACCENT']}; }}
226
+ QToolTip {{
227
+ background: {t['CARD_BG']}; color: {t['FG']};
228
+ border: 1px solid {t['ACCENT']}; border-radius: 4px;
229
+ padding: 4px;
230
+ }}
231
+ QPushButton:pressed{{ background:{t['ACCENT']}; }}
232
+ QPushButton#accent {{
233
+ background:#1e4080; border-color:{t['ACCENT']}; color:#fff;
234
+ }}
235
+ QPushButton#accent:hover {{ background:#2255aa; }}
236
+ QPushButton#color_btn {{
237
+ border-radius:4px; min-width:36px; min-height:24px;
238
+ padding:2px; border:2px solid {t['BORDER']};
239
+ }}
240
+ QPushButton#color_btn:hover {{ border-color:{t['ACCENT']}; }}
241
+ QLabel {{ color:{t['FG']}; }}
242
+ QSlider::groove:horizontal {{
243
+ height:4px; background:{t['BORDER']}; border-radius:2px;
244
+ }}
245
+ QSlider::handle:horizontal {{
246
+ background:{t['ACCENT']}; border-radius:6px;
247
+ width:14px; height:14px; margin:-5px 0;
248
+ }}
249
+ QSlider::sub-page:horizontal {{ background:{t['ACCENT']}; border-radius:2px; }}
250
+ QDoubleSpinBox, QSpinBox, QComboBox {{
251
+ background:{t['DARK_BG']}; border:1px solid {t['BORDER']};
252
+ border-radius:4px; padding:3px 6px; color:{t['FG']};
253
+ }}
254
+ QComboBox::drop-down {{
255
+ border:none; width:20px;
256
+ }}
257
+ QComboBox QAbstractItemView {{
258
+ background:{t['CARD_BG']}; color:{t['FG']};
259
+ border:1px solid {t['BORDER']}; selection-background-color:{t['ACCENT']};
260
+ selection-color:#fff; outline:none;
261
+ }}
262
+ QCheckBox {{
263
+ spacing:6px; color:{t['FG']};
264
+ }}
265
+ QCheckBox::indicator {{
266
+ width:14px; height:14px; border:1px solid {t['BORDER']};
267
+ border-radius:3px; background:{t['DARK_BG']};
268
+ }}
269
+ QCheckBox::indicator:checked {{
270
+ background:{t['ACCENT']}; border-color:{t['ACCENT']};
271
+ }}
272
+ QGroupBox {{
273
+ color:{t['ACCENT']}; border:1px solid {t['BORDER']};
274
+ border-radius:6px; margin-top:10px; font-size:11px;
275
+ }}
276
+ QGroupBox::title {{ subcontrol-origin:margin; left:8px; padding:0 4px; }}
277
+ QStatusBar {{ background:{t['PANEL_BG']}; color:{t['FG_DIM']}; font-size:11px; }}
278
+ QScrollArea {{ background:transparent; border:none; }}
279
+ QScrollArea > QWidget > QWidget {{ background:transparent; }}
280
+ QDialogButtonBox QPushButton {{ min-width:80px; }}
281
+ QTabWidget::pane {{ border: 1px solid {t['BORDER']}; background: transparent; }}
282
+ QTabBar::tab {{
283
+ background: {t['DARK_BG']}; border: 1px solid {t['BORDER']};
284
+ padding: 5px 12px; border-top-left-radius: 4px; border-top-right-radius: 4px;
285
+ }}
286
+ QTabBar::tab:selected {{ background: {t['CARD_BG']}; border-bottom-color: {t['CARD_BG']}; }}
287
+ QTableWidget {{ background: {t['DARK_BG']}; gridline-color: {t['BORDER']}; border: 1px solid {t['BORDER']}; }}
288
+ QHeaderView::section {{ background: {t['CARD_BG']}; color: {t['FG']}; border: 1px solid {t['BORDER']}; }}
289
+
290
+ QLabel#dim {{
291
+ color:{t['FG_DIM']}; font-size:11px;
292
+ }}
293
+ QLabel#zoom_label {{
294
+ color:{t['ACCENT']}; font-size:11px; padding:0 4px;
295
+ }}
296
+ QWidget#sidebar {{
297
+ background:{t['PANEL_BG']}; border-right:1px solid {t['BORDER']};
298
+ }}
299
+ QLabel#mol_name {{
300
+ color:{t['FG']}; font-weight:bold; font-size:13px; letter-spacing:1px;
301
+ }}
302
+ QLabel#hint {{
303
+ color:{t['FG_DIM']}; font-size:10px; line-height:160%;
304
+ }}
305
+ QToolBar {{
306
+ background:{t['PANEL_BG']}; border-bottom:1px solid {t['BORDER']};
307
+ spacing: 4px; padding: 2px;
308
+ }}
309
+ QToolButton {{
310
+ border-radius: 4px; padding: 4px; color: {t['FG']};
311
+ border: 1px solid transparent;
312
+ }}
313
+ QToolButton:hover {{ background: {t['BORDER']}; }}
314
+ QToolButton:checked {{
315
+ background: {t['ACCENT']}; color: white;
316
+ border: 1px solid {t['ACCENT']};
317
+ }}
318
+ QToolButton:checked {{
319
+ background: {t['ACCENT']}; color: white;
320
+ border: 1px solid {t['ACCENT']};
321
+ }}
322
+ """
323
+
324
+
325
+ # ─────────────────────────────────────────────────────────────────────────────
326
+ # COLOUR SWATCH BUTTON
327
+ # ─────────────────────────────────────────────────────────────────────────────
328
+
329
+ def color_pixmap(hex_color: str, size: int = 22) -> QPixmap:
330
+ px = QPixmap(size, size)
331
+ px.fill(QColor(hex_color))
332
+ return px
333
+
334
+ class ColorButton(QPushButton):
335
+ """Push-button that shows a solid colour swatch and opens a colour dialog."""
336
+ colorChanged = pyqtSignal(str) # emits new hex string
337
+
338
+ def __init__(self, initial_color: str = "#ffffff", parent=None):
339
+ super().__init__(parent)
340
+ self.setObjectName("color_btn")
341
+ self.setFixedSize(36, 26)
342
+ self._color = initial_color
343
+ self._update_swatch()
344
+ self.clicked.connect(self._pick)
345
+
346
+ def color(self) -> str:
347
+ return self._color
348
+
349
+ def set_color(self, hex_color: str, emit: bool = True):
350
+ orig = self._color
351
+ self._color = hex_color
352
+ self._update_swatch()
353
+ if emit and orig != hex_color:
354
+ self.colorChanged.emit(self._color)
355
+
356
+ def _update_swatch(self):
357
+ # The border/size is handled by the main stylesheet via #color_btn
358
+ self.setStyleSheet(f"background:{self._color};")
359
+
360
+ def _pick(self):
361
+ col = QColorDialog.getColor(QColor(self._color), self, "Pick colour")
362
+ if col.isValid():
363
+ self._color = col.name()
364
+ self._update_swatch()
365
+ self.colorChanged.emit(self._color)
366
+
367
+
368
+ # ─────────────────────────────────────────────────────────────────────────────
369
+ # SETTINGS DIALOG (Tabbed: General, Overlay, Atoms & Bonds)
370
+ # ─────────────────────────────────────────────────────────────────────────────
371
+
372
+ POSITION_LABELS = {
373
+ "top-left": "Top left",
374
+ "top": "Top",
375
+ "top-right": "Top right",
376
+ "left": "Left",
377
+ "center": "Center",
378
+ "right": "Right",
379
+ "bottom-left": "Bottom left",
380
+ "bottom": "Bottom",
381
+ "bottom-right": "Bottom right",
382
+ }
383
+
384
+ class LightPositionDialog(QDialog):
385
+ def __init__(self, current="top-left", parent=None):
386
+ super().__init__(parent)
387
+ self.setWindowTitle("Select Light Position")
388
+ self.setFixedSize(320, 260)
389
+ self._selected = current
390
+
391
+ layout = QVBoxLayout(self)
392
+ layout.setSpacing(10)
393
+
394
+ grid = QGridLayout()
395
+ grid.setSpacing(6)
396
+
397
+ positions = [
398
+ ("top-left", 0, 0), ("top", 0, 1), ("top-right", 0, 2),
399
+ ("left", 1, 0), ("center", 1, 1), ("right", 1, 2),
400
+ ("bottom-left", 2, 0), ("bottom", 2, 1), ("bottom-right", 2, 2),
401
+ ]
402
+
403
+ self._btns = {}
404
+ for key, r, c in positions:
405
+ label = POSITION_LABELS[key]
406
+ btn = QPushButton(label)
407
+ btn.setFixedSize(90, 40)
408
+ btn.setCheckable(True)
409
+ btn.setChecked(key == current)
410
+ if key == current:
411
+ btn.setStyleSheet(
412
+ "QPushButton { background-color: #4488cc; color: white; "
413
+ "font-weight: bold; border: 2px solid #66aaff; border-radius: 4px; }"
414
+ )
415
+ btn.clicked.connect(lambda checked, k=key: self._select(k))
416
+ grid.addWidget(btn, r, c)
417
+ self._btns[key] = btn
418
+
419
+ layout.addLayout(grid)
420
+
421
+ btn_row = QHBoxLayout()
422
+ btn_row.addStretch()
423
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
424
+ btns.accepted.connect(self.accept)
425
+ btns.rejected.connect(self.reject)
426
+ btn_row.addWidget(btns)
427
+ layout.addLayout(btn_row)
428
+
429
+ def _select(self, key: str):
430
+ old = self._selected
431
+ self._selected = key
432
+ if old in self._btns:
433
+ self._btns[old].setChecked(False)
434
+ self._btns[old].setStyleSheet("")
435
+ self._btns[key].setChecked(True)
436
+ self._btns[key].setStyleSheet(
437
+ "QPushButton { background-color: #4488cc; color: white; "
438
+ "font-weight: bold; border: 2px solid #66aaff; border-radius: 4px; }"
439
+ )
440
+
441
+ @property
442
+ def position(self) -> str:
443
+ return self._selected
444
+
445
+
446
+ class SettingsDialog(QDialog):
447
+ CONFIG_FILE = os.path.join(os.path.dirname(__file__), "molvector_config.json")
448
+
449
+ def __init__(self, theme, bg_color, atom_scale, bond_width, bond_style, color_overrides,
450
+ atom_border_mode="scaled", atom_border_scale=1.04, atom_border_width=2.0,
451
+ bond_color="#444444", lighting_intensity=1.0,
452
+ light_position="top-left", roughness=1.0,
453
+ show_axes=False, show_principal_axes=False,
454
+ axes_position="bottom-left", principal_axes_position="bottom-left",
455
+ restore_molecule=False, live_callback=None, parent=None):
456
+ super().__init__(parent)
457
+ self.setWindowTitle("Settings")
458
+ self.setMinimumWidth(520)
459
+ self._live_callback = live_callback
460
+ self._color_overrides = color_overrides
461
+
462
+ layout = QVBoxLayout(self)
463
+ layout.setSpacing(10)
464
+
465
+ self.tabs = QTabWidget()
466
+ layout.addWidget(self.tabs)
467
+
468
+ self._build_general_tab(theme, restore_molecule)
469
+ self._build_overlay_tab(bg_color, show_axes, show_principal_axes, axes_position, principal_axes_position)
470
+ self._build_atoms_bonds_tab(atom_scale, bond_width, bond_style,
471
+ atom_border_mode, atom_border_scale, atom_border_width,
472
+ bond_color, lighting_intensity, light_position, roughness)
473
+
474
+ btn_row = QHBoxLayout()
475
+ btn_make_config = QPushButton("Make Default")
476
+ btn_make_config.clicked.connect(self._save_config)
477
+ btn_restore = QPushButton("Restore Defaults")
478
+ btn_restore.clicked.connect(self._restore_defaults)
479
+ btn_row.addWidget(btn_make_config)
480
+ btn_row.addWidget(btn_restore)
481
+ btn_row.addStretch()
482
+ dialog_btns = QDialogButtonBox(
483
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
484
+ )
485
+ dialog_btns.accepted.connect(self.accept)
486
+ dialog_btns.rejected.connect(self.reject)
487
+ btn_row.addWidget(dialog_btns)
488
+ layout.addLayout(btn_row)
489
+
490
+ def _build_general_tab(self, theme, restore_molecule):
491
+ page = QWidget()
492
+ form = QFormLayout(page)
493
+ form.setSpacing(8)
494
+
495
+ self._theme_combo = QComboBox()
496
+ self._theme_combo.addItems(["Dark", "Light"])
497
+ self._theme_combo.setCurrentText(theme.capitalize())
498
+ self._theme_combo.setMinimumWidth(100)
499
+ self._theme_combo.currentTextChanged.connect(self._on_change)
500
+ form.addRow("Theme:", self._theme_combo)
501
+
502
+ self._restore_cb = QCheckBox("Restore molecule at login")
503
+ self._restore_cb.setChecked(restore_molecule)
504
+ self._restore_cb.toggled.connect(self._on_change)
505
+ form.addRow("", self._restore_cb)
506
+
507
+ self.tabs.addTab(page, "&General")
508
+
509
+ def _build_overlay_tab(self, bg_color, show_axes, show_principal_axes, axes_position, principal_axes_position):
510
+ page = QWidget()
511
+ form = QFormLayout(page)
512
+ form.setSpacing(8)
513
+
514
+ self._bg_btn = ColorButton(bg_color)
515
+ self._bg_btn.colorChanged.connect(self._on_change)
516
+ form.addRow("Background:", self._bg_btn)
517
+
518
+ self._show_axes_cb = QCheckBox("Show XYZ Axes")
519
+ self._show_axes_cb.setChecked(show_axes)
520
+ self._show_axes_cb.toggled.connect(self._on_change)
521
+ form.addRow("XYZ axes:", self._show_axes_cb)
522
+
523
+ self._axes_pos_combo = QComboBox()
524
+ self._axes_pos_combo.addItems(["Bottom left", "Bottom right", "Top left", "Top right"])
525
+ self._axes_pos_combo.setCurrentText(axes_position.replace("-", " ").title())
526
+ self._axes_pos_combo.setFixedWidth(120)
527
+ self._axes_pos_combo.currentIndexChanged.connect(self._on_change)
528
+ form.addRow("Position:", self._axes_pos_combo)
529
+
530
+ self._show_principal_axes_cb = QCheckBox("Show a/b/c Axes")
531
+ self._show_principal_axes_cb.setChecked(show_principal_axes)
532
+ self._show_principal_axes_cb.toggled.connect(self._on_change)
533
+ form.addRow("a/b/c axes:", self._show_principal_axes_cb)
534
+
535
+ self._principal_axes_pos_combo = QComboBox()
536
+ self._principal_axes_pos_combo.addItems(["Bottom right", "Bottom left", "Top left", "Top right"])
537
+ self._principal_axes_pos_combo.setCurrentText(principal_axes_position.replace("-", " ").title())
538
+ self._principal_axes_pos_combo.setFixedWidth(120)
539
+ self._principal_axes_pos_combo.currentIndexChanged.connect(self._on_change)
540
+ form.addRow("Position:", self._principal_axes_pos_combo)
541
+
542
+ self.tabs.addTab(page, "&Overlay")
543
+
544
+ def _build_atoms_bonds_tab(self, atom_scale, bond_width, bond_style,
545
+ atom_border_mode, atom_border_scale, atom_border_width,
546
+ bond_color, lighting_intensity, light_position, roughness):
547
+ page = QWidget()
548
+ form = QFormLayout(page)
549
+ form.setSpacing(8)
550
+
551
+ self._edit_colors_btn = QPushButton("Edit Atom Colours…")
552
+ self._edit_colors_btn.clicked.connect(self._edit_atom_colors)
553
+ form.addRow("Atom color:", self._edit_colors_btn)
554
+
555
+ self._ball_slider = QSlider(Qt.Orientation.Horizontal)
556
+ self._ball_slider.setRange(20, 150)
557
+ self._ball_slider.setValue(int(atom_scale * 100))
558
+ self._ball_slider.setFixedWidth(160)
559
+ self._ball_lbl = QLabel(f"{atom_scale:.2f}")
560
+ self._ball_slider.valueChanged.connect(self._on_change)
561
+ ball_row = QHBoxLayout()
562
+ ball_row.addWidget(self._ball_slider)
563
+ ball_row.addWidget(self._ball_lbl)
564
+ form.addRow("Ball size:", ball_row)
565
+
566
+ self._bondw_slider = QSlider(Qt.Orientation.Horizontal)
567
+ self._bondw_slider.setRange(2, 30)
568
+ self._bondw_slider.setValue(int(bond_width))
569
+ self._bondw_slider.setFixedWidth(160)
570
+ self._bondw_lbl = QLabel(f"{bond_width:.0f}")
571
+ self._bondw_slider.valueChanged.connect(self._on_change)
572
+ bondw_row = QHBoxLayout()
573
+ bondw_row.addWidget(self._bondw_slider)
574
+ bondw_row.addWidget(self._bondw_lbl)
575
+ form.addRow("Bond size:", bondw_row)
576
+
577
+ self._style_combo = QComboBox()
578
+ self._style_combo.addItems(["Splitted", "Unicolor", "Gradient"])
579
+ self._style_combo.setMinimumWidth(90)
580
+ self._bond_color = bond_color
581
+ style_row = QHBoxLayout()
582
+ style_row.addWidget(self._style_combo)
583
+ self._unicolor_btn = QPushButton()
584
+ self._unicolor_btn.setFixedSize(28, 22)
585
+ self._unicolor_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
586
+ self._unicolor_btn.clicked.connect(self._pick_unicolor)
587
+ self._update_unicolor_btn()
588
+ style_row.addWidget(self._unicolor_btn)
589
+ self._style_combo.currentTextChanged.connect(self._on_style_change)
590
+ current = bond_style.capitalize() if bond_style != "grey" else "Unicolor"
591
+ self._style_combo.setCurrentText(current)
592
+ self._unicolor_btn.setVisible(current == "Unicolor")
593
+ style_row.addStretch()
594
+ form.addRow("Bond Style:", style_row)
595
+
596
+ self._roughness_slider = QSlider(Qt.Orientation.Horizontal)
597
+ self._roughness_slider.setRange(0, 200)
598
+ self._roughness_slider.setValue(int(roughness * 100))
599
+ self._roughness_slider.setFixedWidth(160)
600
+ self._roughness_lbl = QLabel(f"{roughness:.2f}")
601
+ self._roughness_slider.valueChanged.connect(self._on_change)
602
+ roughness_row = QHBoxLayout()
603
+ roughness_row.addWidget(self._roughness_slider)
604
+ roughness_row.addWidget(self._roughness_lbl)
605
+ form.addRow("Roughness:", roughness_row)
606
+
607
+ self._lighting_slider = QSlider(Qt.Orientation.Horizontal)
608
+ self._lighting_slider.setRange(0, 200)
609
+ self._lighting_slider.setValue(int(lighting_intensity * 100))
610
+ self._lighting_slider.setFixedWidth(160)
611
+ self._lighting_lbl = QLabel(f"{lighting_intensity:.2f}")
612
+ self._lighting_slider.valueChanged.connect(self._on_change)
613
+ lighting_row = QHBoxLayout()
614
+ lighting_row.addWidget(self._lighting_slider)
615
+ lighting_row.addWidget(self._lighting_lbl)
616
+ form.addRow("Lighting:", lighting_row)
617
+
618
+ self._light_position = light_position
619
+ self._light_pos_btn = QPushButton(POSITION_LABELS.get(light_position, light_position))
620
+ self._light_pos_btn.setFixedWidth(160)
621
+ self._light_pos_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
622
+ self._light_pos_btn.clicked.connect(self._pick_light_position)
623
+ form.addRow("Lighting Position:", self._light_pos_btn)
624
+
625
+ self._border_mode = QComboBox()
626
+ self._border_mode.addItems(["None", "Scaled", "Constant"])
627
+ self._border_mode.setMinimumWidth(90)
628
+ self._border_mode.setCurrentText(atom_border_mode.capitalize())
629
+ self._border_mode.currentTextChanged.connect(self._on_mode_change)
630
+
631
+ self._border_slider = QSlider(Qt.Orientation.Horizontal)
632
+ self._border_slider.setFixedWidth(160)
633
+ self._border_slider.valueChanged.connect(self._on_change)
634
+
635
+ border_row = QHBoxLayout()
636
+ border_row.addWidget(self._border_mode)
637
+ border_row.addWidget(self._border_slider)
638
+ form.addRow("Border:", border_row)
639
+
640
+ self._set_border_slider_for_mode(atom_border_mode, atom_border_scale, atom_border_width)
641
+ self._update_border_visibility()
642
+
643
+ self.tabs.addTab(page, "Atoms && Bonds")
644
+
645
+ self._ball_slider.installEventFilter(self)
646
+ self._bondw_slider.installEventFilter(self)
647
+ self._roughness_slider.installEventFilter(self)
648
+ self._lighting_slider.installEventFilter(self)
649
+ self._border_slider.installEventFilter(self)
650
+
651
+ def _set_border_slider_for_mode(self, mode, scale, width):
652
+ if mode == "constant":
653
+ self._border_slider.setRange(1, 20)
654
+ self._border_slider.setValue(int(width))
655
+ else:
656
+ self._border_slider.setRange(100, 150)
657
+ self._border_slider.setValue(int(scale * 100))
658
+
659
+ def _update_border_visibility(self):
660
+ visible = self._border_mode.currentText().lower() != "none"
661
+ if hasattr(self, '_border_slider'):
662
+ self._border_slider.setVisible(visible)
663
+
664
+ def _on_mode_change(self):
665
+ mode = self._border_mode.currentText().lower()
666
+ if mode == "constant":
667
+ self._border_slider.setRange(1, 20)
668
+ self._border_slider.setValue(2)
669
+ else:
670
+ self._border_slider.setRange(100, 150)
671
+ self._border_slider.setValue(104)
672
+ self._on_change()
673
+
674
+ def _on_style_change(self, text: str):
675
+ self._unicolor_btn.setVisible(text == "Unicolor")
676
+ self._on_change()
677
+
678
+ def _on_change(self):
679
+ if hasattr(self, '_ball_lbl'):
680
+ self._ball_lbl.setText(f"{self.ball_scale:.2f}")
681
+ self._bondw_lbl.setText(f"{self.bond_width:.0f}")
682
+ self._lighting_lbl.setText(f"{self.lighting_intensity:.2f}")
683
+ self._roughness_lbl.setText(f"{self.roughness:.2f}")
684
+ self._update_border_visibility()
685
+ if self._live_callback:
686
+ self._live_callback(
687
+ self.theme, self.bg_color,
688
+ self.ball_scale, self.bond_width, self.bond_style,
689
+ self._color_overrides, self.border_mode, self.border_scale,
690
+ self.border_width, self.bond_color,
691
+ self.lighting_intensity, self.light_position,
692
+ self.roughness,
693
+ self.show_axes, self.show_principal_axes,
694
+ self.axes_position, self.principal_axes_position,
695
+ )
696
+
697
+ def eventFilter(self, obj, event):
698
+ if event.type() == QEvent.Type.MouseButtonDblClick:
699
+ cfg = self.load_config() or {}
700
+ acfg = cfg.get("atoms_bonds", {})
701
+ if obj is self._ball_slider:
702
+ self._ball_slider.setValue(int(acfg.get("atom_scale", 0.7) * 100))
703
+ elif obj is self._bondw_slider:
704
+ self._bondw_slider.setValue(int(acfg.get("bond_width_px", 10)))
705
+ elif obj is self._roughness_slider:
706
+ self._roughness_slider.setValue(int(acfg.get("roughness", 1.0) * 100))
707
+ elif obj is self._lighting_slider:
708
+ self._lighting_slider.setValue(int(acfg.get("lighting_intensity", 1.0) * 100))
709
+ elif obj is self._border_slider:
710
+ mode = self._border_mode.currentText().lower()
711
+ if mode == "constant":
712
+ self._border_slider.setValue(int(acfg.get("atom_border_width", 2)))
713
+ else:
714
+ self._border_slider.setValue(int(acfg.get("atom_border_scale", 1.04) * 100))
715
+ return True
716
+ return super().eventFilter(obj, event)
717
+
718
+ def _pick_unicolor(self):
719
+ col = QColorDialog.getColor(QColor(self._bond_color), self, "Choose Bond Color")
720
+ if col.isValid():
721
+ self._bond_color = col.name()
722
+ self._update_unicolor_btn()
723
+ self._on_change()
724
+
725
+ def _update_unicolor_btn(self):
726
+ self._unicolor_btn.setStyleSheet(
727
+ f"background-color: {self._bond_color}; border: 1px solid #888; border-radius: 3px;"
728
+ )
729
+
730
+ def _pick_light_position(self):
731
+ dlg = LightPositionDialog(self._light_position, parent=self)
732
+ if dlg.exec() == QDialog.DialogCode.Accepted:
733
+ self._light_position = dlg.position
734
+ self._light_pos_btn.setText(POSITION_LABELS.get(self._light_position, self._light_position))
735
+ self._on_change()
736
+
737
+ def _edit_atom_colors(self):
738
+ mol = self.parent()._canvas.molecule if self.parent() else None
739
+ if mol is None:
740
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
741
+ return
742
+ elements = sorted({a.element for a in mol.atoms})
743
+ dlg = AtomColorDialog(elements, self._color_overrides, parent=self)
744
+ if dlg.exec() == QDialog.DialogCode.Accepted:
745
+ self._color_overrides = dlg.get_overrides()
746
+ self._on_change()
747
+
748
+ @property
749
+ def theme(self) -> str:
750
+ return self._theme_combo.currentText().lower()
751
+
752
+ @property
753
+ def bg_color(self) -> str:
754
+ return self._bg_btn.color()
755
+
756
+ @property
757
+ def restore_molecule(self) -> bool:
758
+ return self._restore_cb.isChecked()
759
+
760
+ @property
761
+ def ball_scale(self) -> float:
762
+ return self._ball_slider.value() / 100.0
763
+
764
+ @property
765
+ def bond_width(self) -> float:
766
+ return float(self._bondw_slider.value())
767
+
768
+ @property
769
+ def bond_style(self) -> str:
770
+ text = self._style_combo.currentText()
771
+ return "unicolor" if text == "Unicolor" else text.lower()
772
+
773
+ @property
774
+ def bond_color(self) -> str:
775
+ return self._bond_color
776
+
777
+ @property
778
+ def border_mode(self) -> str:
779
+ return self._border_mode.currentText().lower()
780
+
781
+ @property
782
+ def border_scale(self) -> float:
783
+ return self._border_slider.value() / 100.0
784
+
785
+ @property
786
+ def border_width(self) -> float:
787
+ return float(self._border_slider.value())
788
+
789
+ @property
790
+ def lighting_intensity(self) -> float:
791
+ return self._lighting_slider.value() / 100.0
792
+
793
+ @property
794
+ def light_position(self) -> str:
795
+ return self._light_position
796
+
797
+ @property
798
+ def roughness(self) -> float:
799
+ return self._roughness_slider.value() / 100.0
800
+
801
+ @property
802
+ def show_axes(self) -> bool:
803
+ return self._show_axes_cb.isChecked()
804
+
805
+ @property
806
+ def show_principal_axes(self) -> bool:
807
+ return self._show_principal_axes_cb.isChecked()
808
+
809
+ @property
810
+ def axes_position(self) -> str:
811
+ return self._axes_pos_combo.currentText().lower().replace(" ", "-")
812
+
813
+ @property
814
+ def principal_axes_position(self) -> str:
815
+ return self._principal_axes_pos_combo.currentText().lower().replace(" ", "-")
816
+
817
+ def _section_key(self) -> str:
818
+ return {0: "general", 1: "overlay", 2: "atoms_bonds"}.get(
819
+ self.tabs.currentIndex(), "general")
820
+
821
+ def _section_data(self, section: str) -> dict:
822
+ if section == "general":
823
+ return {"theme": self.theme, "restore_molecule": self.restore_molecule}
824
+ if section == "overlay":
825
+ return {
826
+ "bg_color": self.bg_color,
827
+ "show_axes": self.show_axes,
828
+ "show_principal_axes": self.show_principal_axes,
829
+ "axes_position": self.axes_position,
830
+ "principal_axes_position": self.principal_axes_position,
831
+ }
832
+ return {
833
+ "atom_scale": self.ball_scale,
834
+ "bond_width_px": self.bond_width,
835
+ "bond_style": self.bond_style,
836
+ "bond_color": self.bond_color,
837
+ "atom_border_mode": self.border_mode,
838
+ "atom_border_scale": self.border_scale,
839
+ "atom_border_width": self.border_width,
840
+ "lighting_intensity": self.lighting_intensity,
841
+ "light_position": self.light_position,
842
+ "roughness": self.roughness,
843
+ "color_overrides": self._color_overrides,
844
+ }
845
+
846
+ def _save_config(self):
847
+ section = self._section_key()
848
+ config = {}
849
+ if os.path.isfile(self.CONFIG_FILE):
850
+ try:
851
+ with open(self.CONFIG_FILE) as f:
852
+ config = json.load(f)
853
+ except Exception:
854
+ pass
855
+ config[section] = self._section_data(section)
856
+ try:
857
+ with open(self.CONFIG_FILE, "w") as f:
858
+ json.dump(config, f, indent=2)
859
+ QMessageBox.information(self, "Config Saved",
860
+ f"{section.title()} settings saved.")
861
+ except Exception as e:
862
+ QMessageBox.critical(self, "Error", f"Could not save config:\n{e}")
863
+
864
+ def _restore_section(self, section: str):
865
+ if section == "general":
866
+ self._theme_combo.setCurrentText("Light")
867
+ self._restore_cb.setChecked(False)
868
+ elif section == "overlay":
869
+ self._bg_btn.set_color("#ffffff")
870
+ self._show_axes_cb.setChecked(False)
871
+ self._show_principal_axes_cb.setChecked(False)
872
+ self._axes_pos_combo.setCurrentText("Bottom left")
873
+ self._principal_axes_pos_combo.setCurrentText("Bottom right")
874
+ else:
875
+ self._ball_slider.setValue(70)
876
+ self._bondw_slider.setValue(10)
877
+ self._style_combo.setCurrentText("Splitted")
878
+ self._bond_color = "#444444"
879
+ self._update_unicolor_btn()
880
+ self._roughness_slider.setValue(100)
881
+ self._lighting_slider.setValue(100)
882
+ self._light_position = "top-left"
883
+ self._light_pos_btn.setText("Top left")
884
+ self._border_mode.setCurrentText("Scaled")
885
+ self._border_slider.setValue(104)
886
+ self._color_overrides = {}
887
+
888
+ def _restore_defaults(self):
889
+ section = self._section_key()
890
+ self._restore_section(section)
891
+ self._on_change()
892
+
893
+ @staticmethod
894
+ def load_config():
895
+ path = SettingsDialog.CONFIG_FILE
896
+ if not os.path.isfile(path):
897
+ return None
898
+ try:
899
+ with open(path) as f:
900
+ data = json.load(f)
901
+ except Exception:
902
+ return None
903
+
904
+ TAB_KEYS = {"general", "overlay", "atoms_bonds"}
905
+ if not data.keys() & TAB_KEYS:
906
+ data = {
907
+ "general": {
908
+ "theme": data.get("theme", "light"),
909
+ "restore_molecule": data.get("restore_molecule", False),
910
+ },
911
+ "overlay": {
912
+ "bg_color": data.get("bg_color", "#ffffff"),
913
+ "show_axes": data.get("show_axes", False),
914
+ "show_principal_axes": data.get("show_principal_axes", False),
915
+ "axes_position": data.get("axes_position", "bottom-left"),
916
+ "principal_axes_position": data.get("principal_axes_position", "bottom-right"),
917
+ },
918
+ "atoms_bonds": {
919
+ "atom_scale": data.get("atom_scale", 0.7),
920
+ "bond_width_px": data.get("bond_width_px", 10.0),
921
+ "bond_style": data.get("bond_style", "splitted"),
922
+ "bond_color": data.get("bond_color", "#444444"),
923
+ "atom_border_mode": data.get("atom_border_mode", "scaled"),
924
+ "atom_border_scale": data.get("atom_border_scale", 1.04),
925
+ "atom_border_width": data.get("atom_border_width", 2.0),
926
+ "lighting_intensity": data.get("lighting_intensity", 1.0),
927
+ "light_position": data.get("light_position", "top-left"),
928
+ "roughness": data.get("roughness", 1.0),
929
+ "color_overrides": data.get("color_overrides", {}),
930
+ },
931
+ }
932
+ try:
933
+ with open(path, "w") as f:
934
+ json.dump(data, f, indent=2)
935
+ except Exception:
936
+ pass
937
+ return data
938
+
939
+
940
+ # ─────────────────────────────────────────────────────────────────────────────
941
+ # ATOM COLOUR EDITOR DIALOG
942
+ # ─────────────────────────────────────────────────────────────────────────────
943
+
944
+ class AtomColorDialog(QDialog):
945
+ """Shows one colour-picker row per element present in the molecule."""
946
+
947
+ def __init__(self, elements: list, current_overrides: dict, live_callback=None, parent=None):
948
+ super().__init__(parent)
949
+ self.setWindowTitle("Atom Colours")
950
+ self.setMinimumWidth(360)
951
+ self._live_callback = live_callback
952
+
953
+ layout = QVBoxLayout(self)
954
+ layout.setSpacing(10)
955
+
956
+ lbl = QLabel("Click a swatch to change an element's colour.")
957
+ lbl.setObjectName("dim")
958
+ lbl.setStyleSheet("font-size:11px;")
959
+ layout.addWidget(lbl)
960
+
961
+ scroll = QScrollArea()
962
+ scroll.setWidgetResizable(True)
963
+ inner = QWidget()
964
+ grid = QGridLayout(inner)
965
+ grid.setSpacing(10)
966
+ grid.setContentsMargins(6, 6, 6, 6)
967
+ scroll.setWidget(inner)
968
+ layout.addWidget(scroll)
969
+
970
+ self._buttons: dict[str, ColorButton] = {}
971
+
972
+ for row, elem in enumerate(sorted(elements)):
973
+ default = CPK_BASE.get(elem, "#cc44aa")
974
+ current = current_overrides.get(elem, default)
975
+
976
+ # Element symbol + name label
977
+ sym_lbl = QLabel(f"<b>{elem}</b>")
978
+ sym_lbl.setFixedWidth(28)
979
+ sym_lbl.setStyleSheet("font-size:13px;")
980
+ name_lbl = QLabel(ELEM_FULL_NAME.get(elem, elem))
981
+ name_lbl.setObjectName("dim")
982
+
983
+ btn = ColorButton(current)
984
+ btn.colorChanged.connect(self._trigger_live)
985
+ self._buttons[elem] = btn
986
+
987
+ # Reset to CPK button
988
+ reset_btn = QPushButton("CPK")
989
+ reset_btn.setFixedWidth(40)
990
+ reset_btn.setStyleSheet(f"font-size:10px; padding:2px 4px;")
991
+ reset_btn.setToolTip(f"Reset {elem} to CPK default")
992
+ reset_btn.clicked.connect(lambda _, e=elem, b=btn: b.set_color(CPK_BASE.get(e, "#cc44aa")))
993
+
994
+ grid.addWidget(sym_lbl, row, 0)
995
+ grid.addWidget(name_lbl, row, 1)
996
+ grid.addWidget(btn, row, 2)
997
+ grid.addWidget(reset_btn, row, 3)
998
+
999
+ grid.setColumnStretch(1, 1)
1000
+
1001
+ btns = QDialogButtonBox(
1002
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
1003
+ )
1004
+ btns.accepted.connect(self.accept)
1005
+ btns.rejected.connect(self.reject)
1006
+ layout.addWidget(btns)
1007
+
1008
+ def _trigger_live(self, *_):
1009
+ if self._live_callback:
1010
+ self._live_callback(self.get_overrides())
1011
+
1012
+ def get_overrides(self) -> dict:
1013
+ return {elem: btn.color() for elem, btn in self._buttons.items()}
1014
+
1015
+
1016
+ # ─────────────────────────────────────────────────────────────────────────────
1017
+ # SPECTRUM DIALOG
1018
+ # ─────────────────────────────────────────────────────────────────────────────
1019
+
1020
+ class SpectrumDialog(QDialog):
1021
+ def __init__(self, x_data, y_data, x_label, y_label, title, metadata="", parent=None):
1022
+ super().__init__(parent)
1023
+ self.setWindowTitle(title)
1024
+ self.setMinimumSize(700, 500)
1025
+ self._x = np.array(x_data)
1026
+ self._y = np.array(y_data)
1027
+ self._meta = metadata
1028
+ self._xl, self._yl = x_label, y_label
1029
+ self._default_file = "spectrum.txt"
1030
+
1031
+ layout = QVBoxLayout(self)
1032
+
1033
+ if not HAS_MPL:
1034
+ layout.addWidget(QLabel("Matplotlib not found. Cannot display plot."))
1035
+ else:
1036
+ # Detect current theme from parent
1037
+ theme_name = "dark"
1038
+ p = parent
1039
+ while p:
1040
+ if hasattr(p, "_current_theme"):
1041
+ theme_name = p._current_theme
1042
+ break
1043
+ p = p.parent()
1044
+
1045
+ t = THEMES[theme_name]
1046
+
1047
+ self.fig = Figure(figsize=(6, 4), facecolor=t['CARD_BG'])
1048
+ self.canvas = FigureCanvas(self.fig)
1049
+ self.ax = self.fig.add_subplot(111)
1050
+ self.ax.set_facecolor(t['DARK_BG'])
1051
+
1052
+ # Plot stems/peaks
1053
+ self.ax.vlines(self._x, 0, self._y, color=t['ACCENT'], linewidth=2)
1054
+ self.ax.scatter(self._x, self._y, color=t['ACCENT'], s=20)
1055
+
1056
+ self.ax.set_xlabel(x_label, color=t['FG'])
1057
+ self.ax.set_ylabel(y_label, color=t['FG'])
1058
+ self.ax.tick_params(colors=t['FG_DIM'])
1059
+ for spine in self.ax.spines.values():
1060
+ spine.set_edgecolor(t['BORDER'])
1061
+
1062
+ self.fig.tight_layout()
1063
+ layout.addWidget(self.canvas)
1064
+
1065
+ self.toolbar = NavigationToolbar2QT(self.canvas, self)
1066
+ layout.addWidget(self.toolbar)
1067
+
1068
+ btns = QHBoxLayout()
1069
+ btn_export = QPushButton("Export Data (.txt)")
1070
+ btn_export.clicked.connect(self._export)
1071
+ btns.addStretch()
1072
+ btns.addWidget(btn_export)
1073
+
1074
+ close_btn = QPushButton("Close")
1075
+ close_btn.clicked.connect(self.accept)
1076
+ btns.addWidget(close_btn)
1077
+ layout.addLayout(btns)
1078
+
1079
+ def set_default_filename(self, name: str):
1080
+ self._default_file = name
1081
+
1082
+ def _export(self):
1083
+ path, _ = QFileDialog.getSaveFileName(self, "Export Spectrum", self._default_file, "Text files (*.txt)")
1084
+ if not path:
1085
+ return
1086
+ try:
1087
+ with open(path, "w", encoding="utf-8") as f:
1088
+ if self._meta:
1089
+ for line in self._meta.splitlines():
1090
+ f.write(f"# {line}\n")
1091
+ f.write(f"# {self._xl}\t{self._yl}\n")
1092
+ for xi, yi in zip(self._x, self._y):
1093
+ f.write(f"{xi:.6f}\t{yi:.6f}\n")
1094
+ QMessageBox.information(self, "Export Successful", f"Data saved to {path}")
1095
+ except Exception as e:
1096
+ QMessageBox.critical(self, "Error", f"Could not export: {e}")
1097
+
1098
+
1099
+ # ─────────────────────────────────────────────────────────────────────────────
1100
+ # CALCULATIONS DIALOG
1101
+ # ─────────────────────────────────────────────────────────────────────────────
1102
+
1103
+ class CalculationsDialog(QDialog):
1104
+ modeSelected = pyqtSignal(object) # VibrationalMode
1105
+ stateSelected = pyqtSignal(object) # ExcitedState
1106
+ viewSpectrum = pyqtSignal(str) # "ir" or "uvvis"
1107
+ animationToggled = pyqtSignal(bool)
1108
+ vectorsToggled = pyqtSignal(bool)
1109
+
1110
+ def __init__(self, mol: Molecule, parent=None):
1111
+ super().__init__(parent)
1112
+ self.setWindowTitle(f"Calculations — {mol.name}")
1113
+ self.setMinimumSize(650, 500)
1114
+ self.mol = mol
1115
+
1116
+ layout = QVBoxLayout(self)
1117
+ self.tabs = QTabWidget()
1118
+ layout.addWidget(self.tabs)
1119
+
1120
+ # ── General Tab (first position) ──
1121
+ gen_page = QWidget()
1122
+ gpl = QVBoxLayout(gen_page)
1123
+ form = QFormLayout()
1124
+ form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
1125
+
1126
+ def sel_label(text):
1127
+ lbl = QLabel(text)
1128
+ lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
1129
+ return lbl
1130
+
1131
+ if mol.g16_point_group is not None:
1132
+ form.addRow("Point Group:", sel_label(mol.g16_point_group))
1133
+ if mol.g16_opt_energy is not None:
1134
+ form.addRow("Energy:", sel_label(f"{mol.g16_opt_energy:.8f} Ha"))
1135
+ elif mol.g16_scf_energy is not None:
1136
+ form.addRow("Energy:", sel_label(f"{mol.g16_scf_energy:.8f} Ha"))
1137
+ if mol.g16_rotconst is not None:
1138
+ a, b, c = mol.g16_rotconst
1139
+ rc = sel_label(f"A = {a:.4f} MHz\nB = {b:.4f} MHz\nC = {c:.4f} MHz")
1140
+ form.addRow("Rotational Constants:", rc)
1141
+ if mol.g16_dipole is not None:
1142
+ x, y, z, t = mol.g16_dipole
1143
+ dm = sel_label(f"X = {x:.4f} D\nY = {y:.4f} D\nZ = {z:.4f} D\nTotal = {t:.4f} D")
1144
+ form.addRow("Dipole Moment:", dm)
1145
+ if mol.g16_nproc is not None:
1146
+ form.addRow("Processors:", sel_label(str(mol.g16_nproc)))
1147
+ if mol.g16_mem is not None:
1148
+ form.addRow("Memory:", sel_label(mol.g16_mem))
1149
+ if mol.g16_chk is not None:
1150
+ form.addRow("Checkpoint:", sel_label(mol.g16_chk))
1151
+
1152
+ if form.rowCount() > 0:
1153
+ gpl.addLayout(form)
1154
+ gpl.addStretch()
1155
+ self.tabs.addTab(gen_page, "General")
1156
+
1157
+ # ── Frequencies Tab ──
1158
+ if mol.vibrational_modes:
1159
+ freq_page = QWidget()
1160
+ fpl = QVBoxLayout(freq_page)
1161
+
1162
+ self._freq_table = QTableWidget(len(mol.vibrational_modes), 3)
1163
+ self._freq_table.setHorizontalHeaderLabels(["Mode", "Freq (cm⁻¹)", "Intensity (km/mol)"])
1164
+ self._freq_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
1165
+ self._freq_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
1166
+ self._freq_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
1167
+ self._freq_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
1168
+
1169
+ for i, m in enumerate(mol.vibrational_modes):
1170
+ self._freq_table.setItem(i, 0, QTableWidgetItem(str(m.index)))
1171
+ self._freq_table.setItem(i, 1, QTableWidgetItem(f"{m.frequency:.2f}"))
1172
+ self._freq_table.setItem(i, 2, QTableWidgetItem(f"{m.intensity:.2f}"))
1173
+
1174
+ self._freq_table.itemSelectionChanged.connect(lambda t=self._freq_table: self._on_freq_sel(t))
1175
+ fpl.addWidget(self._freq_table)
1176
+
1177
+ btn_ir = QPushButton("View IR Spectrum…")
1178
+ btn_ir.clicked.connect(lambda: self.viewSpectrum.emit("ir"))
1179
+ fpl.addWidget(btn_ir)
1180
+
1181
+ # Animation control
1182
+ self._anim_check = QCheckBox("Show animation")
1183
+ self._anim_check.setObjectName("dim")
1184
+ self._anim_check.toggled.connect(self.animationToggled.emit)
1185
+ fpl.addWidget(self._anim_check)
1186
+
1187
+ # Vector control
1188
+ self._vector_check = QCheckBox("Show displacement vectors")
1189
+ self._vector_check.setChecked(False)
1190
+ self._vector_check.setObjectName("dim")
1191
+ self._vector_check.toggled.connect(self.vectorsToggled.emit)
1192
+ fpl.addWidget(self._vector_check)
1193
+
1194
+ btn_export_freq = QPushButton("Export Data (.txt)")
1195
+ btn_export_freq.clicked.connect(lambda: self._export_table(self._freq_table, "frequencies"))
1196
+ fpl.addWidget(btn_export_freq)
1197
+
1198
+ self.tabs.addTab(freq_page, "Frequencies")
1199
+
1200
+ # ── TDDFT Tab ──
1201
+ if mol.excited_states:
1202
+ td_page = QWidget()
1203
+ tpl = QVBoxLayout(td_page)
1204
+
1205
+ self._state_table = QTableWidget(len(mol.excited_states), 4)
1206
+ self._state_table.setHorizontalHeaderLabels(["State", "Energy (eV)", "Wavelength (nm)", "f"])
1207
+ self._state_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
1208
+ self._state_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
1209
+ self._state_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
1210
+ self._state_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
1211
+
1212
+ for i, s in enumerate(mol.excited_states):
1213
+ self._state_table.setItem(i, 0, QTableWidgetItem(f"S{s.index} ({s.symmetry})"))
1214
+ self._state_table.setItem(i, 1, QTableWidgetItem(f"{s.energy_ev:.4f}"))
1215
+ self._state_table.setItem(i, 2, QTableWidgetItem(f"{s.wavelength_nm:.2f}"))
1216
+ self._state_table.setItem(i, 3, QTableWidgetItem(f"{s.oscillator_strength:.4f}"))
1217
+
1218
+ self._state_table.itemSelectionChanged.connect(lambda t=self._state_table: self._on_state_sel(t))
1219
+ tpl.addWidget(self._state_table)
1220
+
1221
+ btn_uv = QPushButton("View UV-Vis Spectrum…")
1222
+ btn_uv.clicked.connect(lambda: self.viewSpectrum.emit("uvvis"))
1223
+ tpl.addWidget(btn_uv)
1224
+
1225
+ btn_export_state = QPushButton("Export Data (.txt)")
1226
+ btn_export_state.clicked.connect(lambda: self._export_table(self._state_table, "excited_states"))
1227
+ tpl.addWidget(btn_export_state)
1228
+
1229
+ self.tabs.addTab(td_page, "Excited States (TDDFT)")
1230
+
1231
+ close_btn = QPushButton("Close")
1232
+ close_btn.clicked.connect(self.accept)
1233
+ layout.addWidget(close_btn)
1234
+
1235
+ def _on_freq_sel(self, table):
1236
+ rows = table.selectedItems()
1237
+ if not rows: return
1238
+ idx = int(rows[0].text()) - 1
1239
+ self.modeSelected.emit(self.mol.vibrational_modes[idx])
1240
+
1241
+ def _on_state_sel(self, table):
1242
+ rows = table.selectedItems()
1243
+ if not rows: return
1244
+ # Extract "S1" -> 1
1245
+ row_text = rows[0].text()
1246
+ idx = int(row_text.split()[0][1:]) - 1
1247
+ self.stateSelected.emit(self.mol.excited_states[idx])
1248
+
1249
+ def _export_table(self, table, kind):
1250
+ safe = get_safe_filename(self.mol.name)
1251
+ default_file = f"{safe}_{kind}.txt"
1252
+ path, _ = QFileDialog.getSaveFileName(self, "Export Table", default_file, "Text files (*.txt)")
1253
+ if not path:
1254
+ return
1255
+ try:
1256
+ with open(path, "w", encoding="utf-8") as f:
1257
+ f.write(f"# Molecule: {self.mol.name}\n")
1258
+ f.write(f"# Calculation: {kind}\n")
1259
+ headers = [table.horizontalHeaderItem(i).text() for i in range(table.columnCount())]
1260
+ f.write("# " + "\t".join(headers) + "\n")
1261
+ for row in range(table.rowCount()):
1262
+ vals = []
1263
+ for col in range(table.columnCount()):
1264
+ item = table.item(row, col)
1265
+ vals.append(item.text() if item else "")
1266
+ f.write("\t".join(vals) + "\n")
1267
+ QMessageBox.information(self, "Export Successful", f"Data saved to {path}")
1268
+ except Exception as e:
1269
+ QMessageBox.critical(self, "Error", f"Could not export: {e}")
1270
+
1271
+
1272
+ # ─────────────────────────────────────────────────────────────────────────────
1273
+ # G16 INPUT GENERATOR DIALOG
1274
+ # ─────────────────────────────────────────────────────────────────────────────
1275
+
1276
+ class G16InputDialog(QDialog):
1277
+ def __init__(self, mol, parent=None):
1278
+ super().__init__(parent)
1279
+ self.setWindowTitle("Generate G16 Input")
1280
+ self.setMinimumSize(680, 540)
1281
+ self.setStyleSheet("""
1282
+ QComboBox:disabled, QSpinBox:disabled {
1283
+ background: #e8e8e8;
1284
+ color: #aaa;
1285
+ border: 1px solid #ccc;
1286
+ }
1287
+ """)
1288
+ self.mol = mol
1289
+ self._generating = False
1290
+ self._last_generated = ""
1291
+ self._loaded_route = None
1292
+ self._all_fields = []
1293
+
1294
+ layout = QVBoxLayout(self)
1295
+
1296
+ # ── Route card fields (each with custom override) ──
1297
+ form = QFormLayout()
1298
+ form.setSpacing(6)
1299
+ form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
1300
+
1301
+ def combo_row(items):
1302
+ row = QHBoxLayout()
1303
+ combo = QComboBox()
1304
+ combo.addItems(items + ["Custom"])
1305
+ combo.setMinimumWidth(120)
1306
+ row.addWidget(combo)
1307
+ custom = QLineEdit()
1308
+ custom.setPlaceholderText("Enter custom value…")
1309
+ custom.setFixedWidth(180)
1310
+ custom.setVisible(False)
1311
+ row.addWidget(custom)
1312
+ row.addStretch()
1313
+ return combo, custom, row
1314
+
1315
+ self._job_combo, self._job_custom, j_row = combo_row(
1316
+ ["opt", "freq", "opt freq", "sp", "scan"])
1317
+ form.addRow("Job type:", j_row)
1318
+
1319
+ self._method_combo, self._method_custom, m_row = combo_row(
1320
+ ["b3lyp", "wb97xd", "m062x", "mp2", "hf", "ccsd(t)"])
1321
+ form.addRow("Method:", m_row)
1322
+
1323
+ self._basis_combo, self._basis_custom, b_row = combo_row(
1324
+ ["6-31g(d)", "6-311+g(d,p)", "aug-cc-pvdz", "def2svp", "def2tzvp"])
1325
+ form.addRow("Basis set:", b_row)
1326
+
1327
+ # Toggle custom field visibility and reconnect signals
1328
+ for combo, custom in [(self._job_combo, self._job_custom),
1329
+ (self._method_combo, self._method_custom),
1330
+ (self._basis_combo, self._basis_custom)]:
1331
+ combo.currentTextChanged.connect(
1332
+ lambda t, c=custom: c.setVisible(t == "Custom"))
1333
+ combo.currentTextChanged.connect(self._on_field_changed)
1334
+ custom.textChanged.connect(self._on_field_changed)
1335
+
1336
+ btn_style = """
1337
+ QSpinBox::up-button, QSpinBox::down-button { width: 24px; }
1338
+ QSpinBox:disabled { background: #e8e8e8; color: #aaa; border: 1px solid #ccc; }
1339
+ """
1340
+
1341
+ chg_layout = QHBoxLayout()
1342
+ self._charge_spin = QSpinBox()
1343
+ self._charge_spin.setRange(-10, 10)
1344
+ self._charge_spin.setValue(mol.charge)
1345
+ self._charge_spin.setMinimumWidth(90)
1346
+ self._charge_spin.setStyleSheet(btn_style)
1347
+ self._charge_spin.valueChanged.connect(self._on_field_changed)
1348
+ chg_layout.addWidget(self._charge_spin)
1349
+ chg_layout.addSpacing(4)
1350
+ chg_layout.addWidget(QLabel("Mult:"))
1351
+ self._mult_spin = QSpinBox()
1352
+ self._mult_spin.setRange(1, 10)
1353
+ self._mult_spin.setValue(1)
1354
+ self._mult_spin.setMinimumWidth(90)
1355
+ self._mult_spin.setStyleSheet(btn_style)
1356
+ self._mult_spin.valueChanged.connect(self._on_field_changed)
1357
+ chg_layout.addWidget(self._mult_spin)
1358
+ chg_layout.addStretch()
1359
+ form.addRow("Charge / Mult:", chg_layout)
1360
+
1361
+ proc_row = QHBoxLayout()
1362
+ self._nproc_spin = QSpinBox()
1363
+ self._nproc_spin.setRange(1, 64)
1364
+ self._nproc_spin.setValue(4)
1365
+ self._nproc_spin.setMinimumWidth(90)
1366
+ self._nproc_spin.setStyleSheet(btn_style)
1367
+ self._nproc_spin.valueChanged.connect(self._on_field_changed)
1368
+ proc_row.addWidget(self._nproc_spin)
1369
+ proc_row.addStretch()
1370
+ form.addRow("Processors:", proc_row)
1371
+
1372
+ mem_row = QHBoxLayout()
1373
+ self._mem_combo = QComboBox()
1374
+ self._mem_combo.addItems(["2", "4", "8", "16", "32", "64"])
1375
+ self._mem_combo.setEditable(True)
1376
+ self._mem_combo.setMinimumWidth(80)
1377
+ self._mem_combo.setCurrentText("8")
1378
+ self._mem_combo.currentTextChanged.connect(self._on_field_changed)
1379
+ mem_row.addWidget(self._mem_combo)
1380
+ mem_row.addStretch()
1381
+ form.addRow("Memory:", mem_row)
1382
+
1383
+ self._custom_pct = QLineEdit()
1384
+ self._custom_pct.setPlaceholderText("e.g. %chk=file.chk")
1385
+ self._custom_pct.textChanged.connect(self._on_field_changed)
1386
+ form.addRow("Custom % line:", self._custom_pct)
1387
+
1388
+ layout.addLayout(form)
1389
+
1390
+ # ── Preview (editable) ──
1391
+ layout.addWidget(QLabel("Preview (editable):"))
1392
+ self._preview = QPlainTextEdit()
1393
+ self._preview.setMinimumHeight(180)
1394
+ self._preview.setStyleSheet("font-family: monospace;")
1395
+ self._preview.textChanged.connect(self._on_preview_edited)
1396
+ layout.addWidget(self._preview)
1397
+
1398
+ # ── Buttons ──
1399
+ btn_layout = QHBoxLayout()
1400
+ btn_save = QPushButton("Save…")
1401
+ btn_save.clicked.connect(self._save)
1402
+ btn_layout.addWidget(btn_save)
1403
+ btn_copy_matrix = QPushButton("Copy matrix")
1404
+ btn_copy_matrix.clicked.connect(self._copy_matrix)
1405
+ btn_layout.addWidget(btn_copy_matrix)
1406
+ self._btn_sync = QPushButton("Sync from fields")
1407
+ self._btn_sync.clicked.connect(self._sync_from_fields)
1408
+ btn_layout.addWidget(self._btn_sync)
1409
+ btn_layout.addStretch()
1410
+ btn_cancel = QPushButton("Cancel")
1411
+ btn_cancel.clicked.connect(self.reject)
1412
+ btn_layout.addWidget(btn_cancel)
1413
+ layout.addLayout(btn_layout)
1414
+
1415
+ # Collect all form fields for enable/disable
1416
+ self._all_fields = [
1417
+ self._job_combo, self._method_combo, self._basis_combo,
1418
+ self._charge_spin, self._mult_spin, self._nproc_spin,
1419
+ self._mem_combo, self._custom_pct,
1420
+ ]
1421
+
1422
+ # Pre-populate fields from loaded molecule header
1423
+ if mol.g16_nproc is not None:
1424
+ self._nproc_spin.blockSignals(True)
1425
+ self._nproc_spin.setValue(mol.g16_nproc)
1426
+ self._nproc_spin.blockSignals(False)
1427
+ if mol.g16_mem is not None:
1428
+ self._mem_combo.blockSignals(True)
1429
+ self._mem_combo.setCurrentText(mol.g16_mem.rstrip("GB"))
1430
+ self._mem_combo.blockSignals(False)
1431
+ if mol.g16_chk is not None:
1432
+ self._custom_pct.setText(f"%chk={mol.g16_chk}")
1433
+ if mol.g16_route is not None:
1434
+ self._loaded_route = mol.g16_route
1435
+ self._parse_route_into_fields(mol.g16_route)
1436
+
1437
+ self._sync_from_fields()
1438
+
1439
+ def _set_fields_enabled(self, enabled: bool):
1440
+ for w in self._all_fields:
1441
+ w.setEnabled(enabled)
1442
+ self._btn_sync.setEnabled(not enabled)
1443
+
1444
+ def _on_preview_edited(self):
1445
+ if self._generating:
1446
+ return
1447
+ if self._preview.toPlainText() == self._last_generated:
1448
+ return
1449
+ self._set_fields_enabled(False)
1450
+
1451
+ def _on_field_changed(self):
1452
+ self._loaded_route = None
1453
+ self._sync_from_fields()
1454
+
1455
+ def _field_val(self, combo, custom) -> str:
1456
+ if combo.currentText() == "Custom":
1457
+ txt = custom.text().strip()
1458
+ return txt if txt else "CUSTOM"
1459
+ return combo.currentText()
1460
+
1461
+ def _set_combo_field(self, combo, custom, value):
1462
+ if not value:
1463
+ return
1464
+ items = [combo.itemText(i).lower() for i in range(combo.count())]
1465
+ if value.lower() in items:
1466
+ idx = items.index(value.lower())
1467
+ combo.blockSignals(True)
1468
+ combo.setCurrentIndex(idx)
1469
+ combo.blockSignals(False)
1470
+ custom.setVisible(False)
1471
+ else:
1472
+ combo.blockSignals(True)
1473
+ combo.setCurrentText("Custom")
1474
+ combo.blockSignals(False)
1475
+ custom.blockSignals(True)
1476
+ custom.setText(value)
1477
+ custom.setVisible(True)
1478
+ custom.blockSignals(False)
1479
+
1480
+ def _parse_route_into_fields(self, route: str):
1481
+ text = route.lstrip("#").strip()
1482
+ tokens = text.split()
1483
+ if not tokens:
1484
+ return
1485
+ slash_idx = -1
1486
+ for i, tok in enumerate(tokens):
1487
+ if "/" in tok:
1488
+ slash_idx = i
1489
+ if slash_idx >= 0:
1490
+ method_basis = tokens[slash_idx]
1491
+ parts = method_basis.split("/", 1)
1492
+ method_val = parts[0]
1493
+ basis_val = parts[1] if len(parts) > 1 else ""
1494
+ if slash_idx + 1 < len(tokens):
1495
+ basis_val = basis_val + " " + " ".join(tokens[slash_idx + 1:])
1496
+ job_val = " ".join(tokens[:slash_idx])
1497
+ else:
1498
+ job_val = text
1499
+ method_val = ""
1500
+ basis_val = ""
1501
+ self._set_combo_field(self._job_combo, self._job_custom, job_val)
1502
+ self._set_combo_field(self._method_combo, self._method_custom, method_val)
1503
+ self._set_combo_field(self._basis_combo, self._basis_custom, basis_val)
1504
+
1505
+ def _build_route(self) -> str:
1506
+ job = self._field_val(self._job_combo, self._job_custom)
1507
+ method = self._field_val(self._method_combo, self._method_custom)
1508
+ basis = self._field_val(self._basis_combo, self._basis_custom)
1509
+ return f"# {job} {method}/{basis}"
1510
+
1511
+ def _build_text(self) -> str:
1512
+ nproc = self._nproc_spin.value()
1513
+ mem = self._mem_combo.currentText().rstrip("GB") + "GB"
1514
+ route = self._loaded_route if self._loaded_route else self._build_route()
1515
+ charge = self._charge_spin.value()
1516
+ mult = self._mult_spin.value()
1517
+ custom_pct = self._custom_pct.text().strip()
1518
+
1519
+ header = [f"%nprocshared={nproc}", f"%mem={mem}"]
1520
+ if custom_pct:
1521
+ header.append(custom_pct)
1522
+ header.append(route)
1523
+
1524
+ lines = [
1525
+ *header,
1526
+ "",
1527
+ self.mol.name,
1528
+ "",
1529
+ f"{charge} {mult}",
1530
+ ]
1531
+ for a in self.mol.atoms:
1532
+ lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
1533
+ lines.extend(["", "", ""])
1534
+ return "\n".join(lines)
1535
+
1536
+ def _sync_from_fields(self):
1537
+ self._generating = True
1538
+ self._last_generated = self._build_text()
1539
+ self._preview.setPlainText(self._last_generated)
1540
+ self._generating = False
1541
+ self._set_fields_enabled(True)
1542
+ self._btn_sync.setEnabled(False)
1543
+
1544
+ def _copy_matrix(self):
1545
+ lines = []
1546
+ for a in self.mol.atoms:
1547
+ lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
1548
+ QApplication.clipboard().setText("\n".join(lines))
1549
+
1550
+ def _save(self):
1551
+ safe = self.mol.name.replace(" ", "_").replace("/", "_") or "input"
1552
+ path, _ = QFileDialog.getSaveFileName(
1553
+ self, "Save Gaussian Input", f"{safe}.gjf",
1554
+ "Gaussian input (*.gjf *.com);;All files (*)"
1555
+ )
1556
+ if not path:
1557
+ return
1558
+ try:
1559
+ with open(path, "w", encoding="utf-8") as f:
1560
+ f.write(self._preview.toPlainText())
1561
+ QMessageBox.information(self, "Saved", f"Gaussian input saved to:\n{path}")
1562
+ except Exception as e:
1563
+ QMessageBox.critical(self, "Error", f"Could not save: {e}")
1564
+
1565
+
1566
+ # ─────────────────────────────────────────────────────────────────────────────
1567
+ # CPK LEGEND PANEL (sidebar)
1568
+ # ─────────────────────────────────────────────────────────────────────────────
1569
+
1570
+ class LegendPanel(QGroupBox):
1571
+ elementColorChanged = pyqtSignal(str, str) # (element_symbol, hex_color)
1572
+ atomColorChanged = pyqtSignal(int, str) # (atom_index, hex_color)
1573
+
1574
+ def __init__(self, parent=None):
1575
+ super().__init__("Elements", parent)
1576
+ self._layout = QVBoxLayout(self)
1577
+ self._layout.setSpacing(4)
1578
+ self._layout.setContentsMargins(10,14,10,10)
1579
+
1580
+ self._rows: list = []
1581
+ self._custom_atom_map: Dict[str, int] = {}
1582
+
1583
+ def update_for(self, mol: Molecule, overrides: dict):
1584
+ for w in self._rows:
1585
+ w.setParent(None)
1586
+ self._rows.clear()
1587
+ self._custom_atom_map.clear()
1588
+
1589
+ # Standard entries — one per element for atoms without custom colors
1590
+ seen_elem: set = set()
1591
+ for atom in mol.atoms:
1592
+ if atom.color:
1593
+ continue
1594
+ e = atom.element
1595
+ if e in seen_elem:
1596
+ continue
1597
+ seen_elem.add(e)
1598
+ color = overrides.get(e, CPK_BASE.get(e, "#cc44aa"))
1599
+ name = ELEM_FULL_NAME.get(e, e)
1600
+ self._add_row(e, color, f"{e} - {name}", clickable=True)
1601
+
1602
+ # Custom entries — atoms with per-atom color override
1603
+ custom_counts: Dict[str, int] = {}
1604
+ for idx, atom in enumerate(mol.atoms):
1605
+ if not atom.color:
1606
+ continue
1607
+ e = atom.element
1608
+ custom_counts[e] = custom_counts.get(e, 0) + 1
1609
+ n = custom_counts[e]
1610
+ label = f"{e} - Custom {n}"
1611
+ self._custom_atom_map[label] = idx
1612
+ self._add_row(label, atom.color, label, clickable=True, custom=True)
1613
+
1614
+ def _add_row(self, key: str, color: str, label: str, clickable: bool = True, custom: bool = False):
1615
+ row = QWidget()
1616
+ rl = QHBoxLayout(row)
1617
+ rl.setContentsMargins(0,0,0,0)
1618
+ rl.setSpacing(7)
1619
+
1620
+ swatch = QPushButton()
1621
+ swatch.setFixedSize(14, 14)
1622
+ swatch.setStyleSheet(
1623
+ f"background:{color}; border-radius:7px; border:1px solid #555;"
1624
+ )
1625
+ if clickable:
1626
+ swatch.setCursor(Qt.CursorShape.PointingHandCursor)
1627
+ swatch.setToolTip(f"Click to change {label} colour")
1628
+ if custom:
1629
+ swatch.clicked.connect(lambda _, lbl=label, c=color: self._pick_atom_color(lbl, c))
1630
+ else:
1631
+ swatch.clicked.connect(lambda _, e=key, c=color: self._pick_color(e, c))
1632
+
1633
+ lbl = QLabel(label)
1634
+ lbl.setObjectName("dim")
1635
+ rl.addWidget(swatch)
1636
+ rl.addWidget(lbl)
1637
+ rl.addStretch()
1638
+
1639
+ self._layout.addWidget(row)
1640
+ self._rows.append(row)
1641
+
1642
+ def _pick_color(self, elem: str, current_hex: str):
1643
+ dlg = QColorDialog(QColor(current_hex), self)
1644
+ dlg.setWindowTitle(f"Pick Colour for {ELEM_FULL_NAME.get(elem, elem)}")
1645
+ if dlg.exec():
1646
+ new_hex = dlg.selectedColor().name()
1647
+ self.elementColorChanged.emit(elem, new_hex)
1648
+
1649
+ self._layout.addStretch()
1650
+
1651
+ def _pick_atom_color(self, label: str, current_hex: str):
1652
+ idx = self._custom_atom_map.get(label)
1653
+ if idx is None:
1654
+ return
1655
+ dlg = QColorDialog(QColor(current_hex), self)
1656
+ dlg.setWindowTitle(f"Pick Colour for {label}")
1657
+ if dlg.exec():
1658
+ new_hex = dlg.selectedColor().name()
1659
+ self.atomColorChanged.emit(idx, new_hex)
1660
+
1661
+
1662
+ # ─────────────────────────────────────────────────────────────────────────────
1663
+ # PERIODIC TABLE DIALOG
1664
+ # ─────────────────────────────────────────────────────────────────────────────
1665
+
1666
+ _PERIODIC_TABLE_LAYOUT = [
1667
+ # (symbol, Z, row, col)
1668
+ ("H",1,0,0), ("D",0,0,4), ("T",-1,0,5), ("13C",600,0,6), ("14C",601,0,7), ("15N",700,0,8), ("He",2,0,17),
1669
+ ("Li",3,1,0), ("Be",4,1,1), ("B",5,1,12), ("C",6,1,13), ("N",7,1,14), ("O",8,1,15), ("F",9,1,16), ("Ne",10,1,17),
1670
+ ("Na",11,2,0), ("Mg",12,2,1), ("Al",13,2,12), ("Si",14,2,13), ("P",15,2,14), ("S",16,2,15), ("Cl",17,2,16), ("Ar",18,2,17),
1671
+ ("K",19,3,0), ("Ca",20,3,1), ("Sc",21,3,2), ("Ti",22,3,3), ("V",23,3,4), ("Cr",24,3,5), ("Mn",25,3,6), ("Fe",26,3,7), ("Co",27,3,8), ("Ni",28,3,9), ("Cu",29,3,10), ("Zn",30,3,11), ("Ga",31,3,12), ("Ge",32,3,13), ("As",33,3,14), ("Se",34,3,15), ("Br",35,3,16), ("Kr",36,3,17),
1672
+ ("Rb",37,4,0), ("Sr",38,4,1), ("Y",39,4,2), ("Zr",40,4,3), ("Nb",41,4,4), ("Mo",42,4,5), ("Tc",43,4,6), ("Ru",44,4,7), ("Rh",45,4,8), ("Pd",46,4,9), ("Ag",47,4,10), ("Cd",48,4,11), ("In",49,4,12), ("Sn",50,4,13), ("Sb",51,4,14), ("Te",52,4,15), ("I",53,4,16), ("Xe",54,4,17),
1673
+ ("Cs",55,5,0), ("Ba",56,5,1), ("La",57,5,2), ("Hf",72,5,3), ("Ta",73,5,4), ("W",74,5,5), ("Re",75,5,6), ("Os",76,5,7), ("Ir",77,5,8), ("Pt",78,5,9), ("Au",79,5,10), ("Hg",80,5,11), ("Tl",81,5,12), ("Pb",82,5,13), ("Bi",83,5,14), ("Po",84,5,15), ("At",85,5,16), ("Rn",86,5,17),
1674
+ ("Fr",87,6,0), ("Ra",88,6,1), ("Ac",89,6,2), ("Rf",104,6,3), ("Db",105,6,4), ("Sg",106,6,5), ("Bh",107,6,6), ("Hs",108,6,7), ("Mt",109,6,8), ("Ds",110,6,9), ("Rg",111,6,10), ("Cn",112,6,11), ("Nh",113,6,12), ("Fl",114,6,13), ("Mc",115,6,14), ("Lv",116,6,15), ("Ts",117,6,16), ("Og",118,6,17),
1675
+ # F-block: lanthanides
1676
+ ("Ce",58,7,2), ("Pr",59,7,3), ("Nd",60,7,4), ("Pm",61,7,5), ("Sm",62,7,6), ("Eu",63,7,7), ("Gd",64,7,8), ("Tb",65,7,9), ("Dy",66,7,10), ("Ho",67,7,11), ("Er",68,7,12), ("Tm",69,7,13), ("Yb",70,7,14), ("Lu",71,7,15),
1677
+ # F-block: actinides
1678
+ ("Th",90,8,2), ("Pa",91,8,3), ("U",92,8,4), ("Np",93,8,5), ("Pu",94,8,6), ("Am",95,8,7), ("Cm",96,8,8), ("Bk",97,8,9), ("Cf",98,8,10), ("Es",99,8,11), ("Fm",100,8,12), ("Md",101,8,13), ("No",102,8,14), ("Lr",103,8,15),
1679
+ ]
1680
+
1681
+ _ELEM_CATEGORY_COLORS = {
1682
+ "nonmetal": "#4CAF50",
1683
+ "noble_gas": "#9C27B0",
1684
+ "alkali_metal": "#FF5722",
1685
+ "alkaline_earth": "#FF9800",
1686
+ "metalloid": "#00BCD4",
1687
+ "halogen": "#1E88E5",
1688
+ "transition": "#42A5F5",
1689
+ "post_transition":"#78909C",
1690
+ "lanthanide": "#E91E63",
1691
+ "actinide": "#D32F2F",
1692
+ "unknown": "#757575",
1693
+ }
1694
+
1695
+ def _element_category(sym: str, Z: int) -> str:
1696
+ if sym in ("D", "T", "13C", "14C", "15N"): return "nonmetal"
1697
+ if Z == 1: return "nonmetal"
1698
+ if Z == 2: return "noble_gas"
1699
+ if 3 <= Z <= 4: return "alkali_metal" if Z == 3 else "alkaline_earth"
1700
+ if 5 <= Z <= 10:
1701
+ if Z == 5: return "metalloid"
1702
+ return "nonmetal" if Z in (6,7,8) else "halogen" if Z in (9,17,35,53,85,117) else "noble_gas"
1703
+ if 11 <= Z <= 18:
1704
+ if Z == 11: return "alkali_metal"
1705
+ if Z == 12: return "alkaline_earth"
1706
+ if Z in (13,): return "post_transition"
1707
+ if Z in (14,): return "metalloid"
1708
+ if Z in (15,16): return "nonmetal"
1709
+ if Z == 17: return "halogen"
1710
+ if Z == 18: return "noble_gas"
1711
+ if 19 <= Z <= 36:
1712
+ if Z in (19,37,55,87): return "alkali_metal"
1713
+ if Z in (20,38,56,88): return "alkaline_earth"
1714
+ if 21 <= Z <= 30: return "transition"
1715
+ if 31 <= Z <= 36:
1716
+ if Z == 31: return "post_transition"
1717
+ if Z in (32,): return "metalloid"
1718
+ if Z in (33,34): return "metalloid" if Z == 33 else "nonmetal"
1719
+ return "halogen" if Z == 35 else "noble_gas"
1720
+ if 37 <= Z <= 54:
1721
+ if Z in (37,55,87): return "alkali_metal"
1722
+ if Z in (38,56,88): return "alkaline_earth"
1723
+ if Z == 39 or (40 <= Z <= 48):
1724
+ return "transition"
1725
+ if 49 <= Z <= 54:
1726
+ if Z == 49: return "post_transition"
1727
+ if Z == 50: return "post_transition"
1728
+ if Z == 51: return "metalloid"
1729
+ if Z == 52: return "metalloid"
1730
+ return "halogen" if Z == 53 else "noble_gas"
1731
+ if 55 <= Z <= 86:
1732
+ if Z in (55,87): return "alkali_metal"
1733
+ if Z in (56,88): return "alkaline_earth"
1734
+ if 57 <= Z <= 71: return "lanthanide"
1735
+ if 72 <= Z <= 80: return "transition"
1736
+ if 81 <= Z <= 86:
1737
+ if Z == 81: return "post_transition"
1738
+ if Z in (82,83): return "post_transition"
1739
+ if Z in (84,): return "post_transition"
1740
+ return "halogen" if Z == 85 else "noble_gas"
1741
+ if 89 <= Z <= 103: return "actinide"
1742
+ if 104 <= Z <= 111: return "transition"
1743
+ if 112 <= Z <= 118:
1744
+ if Z in (113,114,115,116): return "post_transition"
1745
+ if Z == 117: return "halogen"
1746
+ if Z == 118: return "noble_gas"
1747
+ return "unknown"
1748
+
1749
+
1750
+ class PeriodicTableDialog(QDialog):
1751
+ elementSelected = pyqtSignal(str)
1752
+
1753
+ def __init__(self, parent=None, current_element: str = "C"):
1754
+ super().__init__(parent)
1755
+ self.setWindowTitle("Select Element — Periodic Table")
1756
+ self.setModal(True)
1757
+ self.setMinimumSize(1020, 640)
1758
+
1759
+ layout = QVBoxLayout(self)
1760
+ layout.setSpacing(4)
1761
+
1762
+ grid = QGridLayout()
1763
+ grid.setSpacing(3)
1764
+
1765
+ btn_size = 52
1766
+ btn_font = QFont("", 10, QFont.Weight.Bold)
1767
+
1768
+ by_Z = {}
1769
+ for sym, Z, r, c in _PERIODIC_TABLE_LAYOUT:
1770
+ by_Z[Z] = (sym, r, c)
1771
+
1772
+ self._all_buttons: Dict[str, QPushButton] = {}
1773
+
1774
+ for sym, Z, r, c in _PERIODIC_TABLE_LAYOUT:
1775
+ btn = QPushButton(sym)
1776
+ btn.setFixedSize(btn_size, btn_size)
1777
+ btn.setFont(btn_font)
1778
+ tooltip = f"{sym} ({Z}) — {ELEM_FULL_NAME.get(sym, 'Unknown')}"
1779
+ btn.setToolTip(tooltip)
1780
+
1781
+ cat = _element_category(sym, Z)
1782
+ bg = _ELEM_CATEGORY_COLORS.get(cat, "#757575")
1783
+ is_dark = sum(int(bg[i:i+2],16) for i in (1,3,5)) < 400
1784
+ text_color = "#ffffff" if is_dark else "#000000"
1785
+ btn.setStyleSheet(
1786
+ f"QPushButton {{ background:{bg}; color:{text_color}; "
1787
+ f"border:1px solid rgba(0,0,0,0.2); border-radius:4px; }}"
1788
+ f"QPushButton:hover {{ border:2px solid white; }}"
1789
+ )
1790
+
1791
+ is_current = (sym == current_element)
1792
+ if is_current:
1793
+ btn.setStyleSheet(
1794
+ f"QPushButton {{ background:{bg}; color:{text_color}; "
1795
+ f"border:2px solid #FFD700; border-radius:4px; }}"
1796
+ )
1797
+
1798
+ def _make_handler(s):
1799
+ return lambda checked, sym=s: self._select_element(sym)
1800
+
1801
+ btn.clicked.connect(_make_handler(sym))
1802
+ grid.addWidget(btn, r, c)
1803
+ self._all_buttons[sym] = btn
1804
+
1805
+ # Headers for characteristic rows
1806
+ label_font = QFont("", 8)
1807
+ lbl_la = QLabel("Lanthanides")
1808
+ lbl_la.setFont(label_font)
1809
+ lbl_la.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
1810
+ grid.addWidget(lbl_la, 7, 0, 1, 2)
1811
+
1812
+ lbl_ac = QLabel("Actinides")
1813
+ lbl_ac.setFont(label_font)
1814
+ lbl_ac.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
1815
+ grid.addWidget(lbl_ac, 8, 0, 1, 2)
1816
+
1817
+ layout.addLayout(grid)
1818
+
1819
+ # Close button
1820
+ btn_layout = QHBoxLayout()
1821
+ btn_layout.addStretch()
1822
+ close_btn = QPushButton("Cancel")
1823
+ close_btn.clicked.connect(self.reject)
1824
+ btn_layout.addWidget(close_btn)
1825
+ layout.addLayout(btn_layout)
1826
+
1827
+ def _select_element(self, sym: str):
1828
+ self.elementSelected.emit(sym)
1829
+ self.accept()
1830
+
1831
+
1832
+
1833
+
1834
+
1835
+ # ── Shortcut Configuration Dialog ───────────────────────────────────────────
1836
+
1837
+ class ShortcutDialog(QDialog):
1838
+ CONFIG_FILE = os.path.join(os.path.dirname(__file__), "molvector_config.json")
1839
+
1840
+ def __init__(self, shortcut_actions: dict, alt_defaults: dict = None, parent=None):
1841
+ super().__init__(parent)
1842
+ self.setWindowTitle("Configure Shortcuts")
1843
+ self.setMinimumSize(640, 420)
1844
+ self._actions = dict(shortcut_actions)
1845
+ self._alt_defaults = alt_defaults or {}
1846
+ self._initial_primary = {}
1847
+ self._initial_alt = {}
1848
+
1849
+ layout = QVBoxLayout(self)
1850
+
1851
+ # Conflict warning label
1852
+ self._conflict_lbl = QLabel()
1853
+ self._conflict_lbl.setVisible(False)
1854
+ layout.addWidget(self._conflict_lbl)
1855
+
1856
+ # Table
1857
+ self._table = QTableWidget(len(self._actions), 3)
1858
+ self._table.setHorizontalHeaderLabels(["Action", "Primary", "Alt"])
1859
+ self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
1860
+ self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
1861
+ self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
1862
+ self._table.verticalHeader().setVisible(False)
1863
+ self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
1864
+
1865
+ self._editors = []
1866
+ for row, (aid, action) in enumerate(self._actions.items()):
1867
+ name_item = QTableWidgetItem(action.text().replace("&", ""))
1868
+ name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
1869
+ self._table.setItem(row, 0, name_item)
1870
+
1871
+ ks1 = QKeySequenceEdit(action.shortcut())
1872
+ ks1.setClearButtonEnabled(True)
1873
+ self._table.setCellWidget(row, 1, ks1)
1874
+ ks1.keySequenceChanged.connect(self._check_conflicts)
1875
+
1876
+ alt_seq = self._alt_defaults.get(aid, "")
1877
+ ks2 = QKeySequenceEdit(QKeySequence(alt_seq))
1878
+ ks2.setClearButtonEnabled(True)
1879
+ self._table.setCellWidget(row, 2, ks2)
1880
+ ks2.keySequenceChanged.connect(self._check_conflicts)
1881
+
1882
+ self._editors.append((ks1, ks2))
1883
+ self._initial_primary[aid] = ks1.keySequence().toString()
1884
+ self._initial_alt[aid] = ks2.keySequence().toString()
1885
+
1886
+ self._check_conflicts()
1887
+ layout.addWidget(self._table)
1888
+
1889
+ # Buttons
1890
+ btn_row = QHBoxLayout()
1891
+ btn_restore = QPushButton("Restore Defaults")
1892
+ btn_restore.clicked.connect(self._restore_defaults)
1893
+ btn_row.addWidget(btn_restore)
1894
+
1895
+ btn_make_default = QPushButton("Make Default")
1896
+ btn_make_default.clicked.connect(self._make_default)
1897
+ btn_row.addWidget(btn_make_default)
1898
+
1899
+ btn_row.addStretch()
1900
+
1901
+ btn_ok = QPushButton("OK")
1902
+ btn_ok.clicked.connect(self.accept)
1903
+ btn_row.addWidget(btn_ok)
1904
+
1905
+ btn_cancel = QPushButton("Cancel")
1906
+ btn_cancel.clicked.connect(self._on_cancel)
1907
+ btn_row.addWidget(btn_cancel)
1908
+
1909
+ layout.addLayout(btn_row)
1910
+
1911
+ def _check_conflicts(self):
1912
+ usage = {}
1913
+ for row, (aid, action) in enumerate(self._actions.items()):
1914
+ for col, editor in enumerate([self._editors[row][0], self._editors[row][1]]):
1915
+ ks = editor.keySequence()
1916
+ if not ks.isEmpty():
1917
+ seq_str = ks.toString().lower()
1918
+ if seq_str not in usage:
1919
+ usage[seq_str] = []
1920
+ usage[seq_str].append((aid, action.text().replace("&", ""), col))
1921
+
1922
+ conflicts = {k: v for k, v in usage.items() if len(v) > 1}
1923
+
1924
+ if conflicts:
1925
+ lines = []
1926
+ for seq, items in conflicts.items():
1927
+ names = [f"{name} ({'Alt' if c == 1 else 'Primary'})" for _, name, c in items]
1928
+ lines.append(f"⚠ {seq} used by: {', '.join(names)}")
1929
+ self._conflict_lbl.setText(
1930
+ '<span style="color:#ff6b6b; font-weight:bold;">' +
1931
+ "<br>".join(lines) +
1932
+ '</span>'
1933
+ )
1934
+ self._conflict_lbl.setVisible(True)
1935
+ else:
1936
+ self._conflict_lbl.setVisible(False)
1937
+
1938
+ def _restore_defaults(self):
1939
+ for row, (aid, action) in enumerate(self._actions.items()):
1940
+ primary = MainWindow.DEFAULT_SHORTCUTS.get(aid, "")
1941
+ alt = MainWindow.ALT_SHORTCUT_DEFAULTS.get(aid, "")
1942
+ self._editors[row][0].setKeySequence(QKeySequence(primary))
1943
+ self._editors[row][1].setKeySequence(QKeySequence(alt))
1944
+ self._check_conflicts()
1945
+
1946
+ def _make_default(self):
1947
+ primary, alt = self._collect_shortcuts()
1948
+ config = {}
1949
+ try:
1950
+ if os.path.exists(self.CONFIG_FILE):
1951
+ with open(self.CONFIG_FILE, "r", encoding="utf-8") as f:
1952
+ config = json.load(f)
1953
+ except Exception:
1954
+ pass
1955
+ config["shortcuts"] = primary
1956
+ config["alt_shortcuts"] = alt
1957
+ try:
1958
+ with open(self.CONFIG_FILE, "w", encoding="utf-8") as f:
1959
+ json.dump(config, f, indent=2)
1960
+ QMessageBox.information(self, "Saved", f"Shortcuts saved to:\n{self.CONFIG_FILE}")
1961
+ except Exception as e:
1962
+ QMessageBox.critical(self, "Error", f"Could not save shortcuts:\n{e}")
1963
+
1964
+ def _has_changes(self) -> bool:
1965
+ for row, (aid, action) in enumerate(self._actions.items()):
1966
+ ks1 = self._editors[row][0].keySequence().toString()
1967
+ ks2 = self._editors[row][1].keySequence().toString()
1968
+ if ks1 != self._initial_primary.get(aid, "") or ks2 != self._initial_alt.get(aid, ""):
1969
+ return True
1970
+ return False
1971
+
1972
+ def _on_cancel(self):
1973
+ if self._has_changes():
1974
+ ret = QMessageBox.warning(
1975
+ self, "Unsaved Changes",
1976
+ "You have unsaved shortcut changes. Discard them?",
1977
+ QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
1978
+ QMessageBox.StandardButton.Cancel,
1979
+ )
1980
+ if ret == QMessageBox.StandardButton.Discard:
1981
+ self.reject()
1982
+ else:
1983
+ self.reject()
1984
+
1985
+ def closeEvent(self, event):
1986
+ if self._has_changes():
1987
+ ret = QMessageBox.warning(
1988
+ self, "Unsaved Changes",
1989
+ "You have unsaved shortcut changes. Discard them?",
1990
+ QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
1991
+ QMessageBox.StandardButton.Cancel,
1992
+ )
1993
+ if ret == QMessageBox.StandardButton.Discard:
1994
+ event.accept()
1995
+ else:
1996
+ event.ignore()
1997
+ else:
1998
+ event.accept()
1999
+
2000
+ def get_shortcuts(self) -> tuple:
2001
+ return self._collect_shortcuts()
2002
+
2003
+ def _collect_shortcuts(self) -> tuple:
2004
+ primary = {}
2005
+ alt = {}
2006
+ for row, (aid, action) in enumerate(self._actions.items()):
2007
+ ks1 = self._editors[row][0].keySequence()
2008
+ ks2 = self._editors[row][1].keySequence()
2009
+ primary[aid] = ks1.toString() if not ks1.isEmpty() else ""
2010
+ alt[aid] = ks2.toString() if not ks2.isEmpty() else ""
2011
+ return primary, alt
2012
+
2013
+
2014
+ # ─────────────────────────────────────────────────────────────────────────────
2015
+ # Quick SVG Export Dialog ────────────────────────────────────────────────────
2016
+
2017
+ class QuickExportDialog(QDialog):
2018
+ """A dialog with a draggable SVG target for drag-and-drop into external editors."""
2019
+
2020
+ def __init__(self, canvas, parent=None):
2021
+ super().__init__(parent)
2022
+ self._canvas = canvas
2023
+ self._tmp_path = None
2024
+ self.setWindowTitle("Quick SVG Export")
2025
+ self.setFixedSize(320, 220)
2026
+
2027
+ layout = QVBoxLayout(self)
2028
+ layout.setSpacing(12)
2029
+
2030
+ lbl = QLabel("Drag the icon into Inkscape or another SVG editor:")
2031
+ lbl.setWordWrap(True)
2032
+ layout.addWidget(lbl)
2033
+
2034
+ self._drag_widget = _DragWidget(self._get_svg_path, self)
2035
+ self._drag_widget.setFixedSize(120, 80)
2036
+ layout.addWidget(self._drag_widget, alignment=Qt.AlignmentFlag.AlignCenter)
2037
+
2038
+ note = QLabel("Note: in Inkscape, you may have to drag the molecule to see gradients.")
2039
+ note.setObjectName("dim")
2040
+ note.setWordWrap(True)
2041
+ note.setStyleSheet("font-size:10px;")
2042
+ layout.addWidget(note)
2043
+
2044
+ btn_close = QPushButton("Close")
2045
+ btn_close.clicked.connect(self.close)
2046
+ layout.addWidget(btn_close, alignment=Qt.AlignmentFlag.AlignCenter)
2047
+
2048
+ def _get_svg_path(self) -> str:
2049
+ if self._tmp_path is None:
2050
+ svg_data = self._canvas.get_svg_bytes(export_mode=True)
2051
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
2052
+ f.write(svg_data)
2053
+ self._tmp_path = f.name
2054
+ return self._tmp_path
2055
+
2056
+ def closeEvent(self, event):
2057
+ if self._tmp_path and os.path.exists(self._tmp_path):
2058
+ try:
2059
+ os.unlink(self._tmp_path)
2060
+ except OSError:
2061
+ pass
2062
+ self._tmp_path = None
2063
+ super().closeEvent(event)
2064
+
2065
+
2066
+ class _DragWidget(QPushButton):
2067
+ """A draggable pushbutton that initiates a file drag on mouse move."""
2068
+
2069
+ def __init__(self, get_svg_path_cb, parent=None):
2070
+ super().__init__("Drag SVG", parent)
2071
+ self._get_svg_path = get_svg_path_cb
2072
+ self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
2073
+ self.setToolTip("Drag this into an SVG editor")
2074
+ self.setStyleSheet("""
2075
+ QPushButton {
2076
+ border: 2px dashed #888;
2077
+ border-radius: 8px;
2078
+ font-size: 14px;
2079
+ background: #f0f0f0;
2080
+ padding: 8px;
2081
+ }
2082
+ QPushButton:hover {
2083
+ border-color: #4a9eff;
2084
+ background: #e0edff;
2085
+ }
2086
+ """)
2087
+
2088
+ def mousePressEvent(self, event):
2089
+ if event.button() == Qt.MouseButton.LeftButton:
2090
+ self._drag_start = event.position().toPoint()
2091
+ super().mousePressEvent(event)
2092
+
2093
+ def mouseMoveEvent(self, event):
2094
+ if not (event.buttons() & Qt.MouseButton.LeftButton):
2095
+ return
2096
+ if (event.position().toPoint() - self._drag_start).manhattanLength() < QApplication.startDragDistance():
2097
+ return
2098
+
2099
+ svg_path = self._get_svg_path()
2100
+
2101
+ mime = QMimeData()
2102
+ mime.setUrls([QUrl.fromLocalFile(svg_path)])
2103
+
2104
+ with open(svg_path, "rb") as f:
2105
+ mime.setData("image/svg+xml", f.read())
2106
+
2107
+ drag = QDrag(self)
2108
+ drag.setMimeData(mime)
2109
+
2110
+ pixmap = self.grab().scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
2111
+ drag.setPixmap(pixmap)
2112
+ drag.setHotSpot(self.rect().center())
2113
+
2114
+ drag.exec(Qt.DropAction.CopyAction)
2115
+
2116
+
2117
+ # ─────────────────────────────────────────────────────────────────────────────
2118
+ # INTERACTIVE CANVAS
2119
+ # ─────────────────────────────────────────────────────────────────────────────
2120
+
2121
+ def _ideal_directions(n: int, angle_rad: float):
2122
+ """Return n unit vectors in ideal VSEPR-like geometry (tetrahedral, trigonal, bent)."""
2123
+ if n == 4:
2124
+ a = 1.0 / math.sqrt(3.0)
2125
+ return [np.array([a,a,a]), np.array([a,-a,-a]),
2126
+ np.array([-a,a,-a]), np.array([-a,-a,a])]
2127
+ elif n == 3:
2128
+ s3 = math.sqrt(3.0)/2.0
2129
+ return [np.array([1.0,0.0,0.0]), np.array([-0.5,s3,0.0]), np.array([-0.5,-s3,0.0])]
2130
+ elif n == 2:
2131
+ half = angle_rad / 2.0
2132
+ return [np.array([math.sin(half),0.0,math.cos(half)]),
2133
+ np.array([-math.sin(half),0.0,math.cos(half)])]
2134
+ dirs = []
2135
+ for i in range(n):
2136
+ theta = 2.0 * math.pi * i / n
2137
+ phi = math.acos(2.0 * i / n - 1.0)
2138
+ dirs.append(np.array([math.sin(phi)*math.cos(theta),
2139
+ math.sin(phi)*math.sin(theta), math.cos(phi)]))
2140
+ return dirs
2141
+
2142
+
2143
+ class MoleculeCanvas(QSvgWidget):
2144
+ rotationChanged = pyqtSignal()
2145
+ fileDropped = pyqtSignal(str)
2146
+ moleculeChanged = pyqtSignal() # Emitted when atoms/bonds change
2147
+ requestHistorySave = pyqtSignal() # Emitted BEFORE a change
2148
+
2149
+ def __init__(self, parent=None):
2150
+ super().__init__(parent)
2151
+ self.molecule: Molecule | None = None
2152
+ self._rot = np.eye(3)
2153
+ self._zoom = 1.0
2154
+ self._pan = np.array([0.0, 0.0])
2155
+ self._axes_ref = np.eye(3)
2156
+ self._default_view = np.eye(3)
2157
+ self._drag_start: QPoint | None = None
2158
+ self._drag_mode = "none"
2159
+
2160
+ self.setMinimumSize(500, 450)
2161
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2162
+ self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
2163
+ self.setAcceptDrops(True)
2164
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
2165
+ self.setMouseTracking(True)
2166
+ self._show_vectors = False
2167
+ self.build_mode = False
2168
+ self.selection_mode = False
2169
+ self.align_mode = False
2170
+ self.build_element = "C"
2171
+ self._bonding_from: int | None = None
2172
+ self._mouse_pos: QPoint | None = None
2173
+
2174
+ # Selection state
2175
+ self.selected_atoms: set = set()
2176
+ self._sel_drag_start: QPoint | None = None
2177
+ self._sel_rect: QRectF | None = None
2178
+
2179
+ # Atom dragging (Alt+click in build/select mode)
2180
+ self._drag_atom_idx: int | None = None
2181
+ self._align_axis: np.ndarray | None = None # bond axis for align rotate-around-bond drag
2182
+
2183
+ # Render parameters — all public, set by MainWindow
2184
+ self.base_scale = 110.0
2185
+ self.atom_scale = 0.7
2186
+ self.bond_width_px = 10.0
2187
+ self.background = "#ffffff"
2188
+ self.bond_style = "splitted"
2189
+ self.bond_color = "#444444"
2190
+ self.atom_border_mode = "scaled"
2191
+ self.atom_border_scale = 1.04
2192
+ self.atom_border_width = 2.0
2193
+ self.color_overrides: dict = {}
2194
+ self.lighting_intensity: float = 1.0
2195
+ self.light_position: str = "Top left"
2196
+ self.roughness: float = 1.0
2197
+ self.show_axes: bool = False
2198
+ self.show_principal_axes: bool = False
2199
+ self.axes_position: str = "bottom-left"
2200
+ self.principal_axes_position: str = "bottom-right"
2201
+ self.animation_phase: float = 0.0
2202
+ self.animation_amplitude: float = 0.0
2203
+
2204
+ self._render_timer = QTimer()
2205
+ self._render_timer.setSingleShot(True)
2206
+ self._render_timer.timeout.connect(self._do_render)
2207
+
2208
+ self.arrows: Optional[List[Tuple[int, float, float, float, str]]] = None
2209
+ self.active_vectors: Optional[np.ndarray] = None
2210
+ self.animation_phase: float = 0.0
2211
+ self.animation_amplitude: float = 0.0
2212
+
2213
+ # Build options
2214
+ self.build_mode: bool = False
2215
+ self.build_element: str = "C"
2216
+ self.auto_adjust_h: bool = False
2217
+ self.bond_order_disabled: bool = False
2218
+ self._saved_bond_orders: list[int] | None = None
2219
+
2220
+ # ── drag and drop ─────────────────────────────────────────────────────────
2221
+
2222
+ def dragEnterEvent(self, event):
2223
+ if event.mimeData().hasUrls():
2224
+ event.setDropAction(Qt.DropAction.CopyAction)
2225
+ event.accept()
2226
+ else:
2227
+ event.ignore()
2228
+
2229
+ def dragMoveEvent(self, event):
2230
+ if event.mimeData().hasUrls():
2231
+ event.setDropAction(Qt.DropAction.CopyAction)
2232
+ event.accept()
2233
+ else:
2234
+ event.ignore()
2235
+
2236
+ def dropEvent(self, event):
2237
+ urls = event.mimeData().urls()
2238
+ if urls and urls[0].isLocalFile():
2239
+ path = urls[0].toLocalFile()
2240
+ self.fileDropped.emit(path)
2241
+ event.setDropAction(Qt.DropAction.CopyAction)
2242
+ event.accept()
2243
+ else:
2244
+ event.ignore()
2245
+
2246
+ # ── public ───────────────────────────────────────────────────────────────
2247
+
2248
+ def load_molecule(self, mol: Molecule):
2249
+ self.molecule = mol
2250
+ self._rot = np.eye(3)
2251
+ self._zoom = 1.0
2252
+ self._pan = np.array([0.0, 0.0])
2253
+ self._axes_ref = np.eye(3)
2254
+ self._default_view = np.eye(3)
2255
+ self.selected_atoms.clear()
2256
+ # Auto-scale: fit the molecule to 80% of the smaller canvas dimension
2257
+ positions = np.array([[a.x, a.y, a.z] for a in mol.atoms])
2258
+ if len(positions) > 0:
2259
+ centroid = positions.mean(axis=0)
2260
+ centered = positions - centroid
2261
+ max_extent = np.max(np.linalg.norm(centered, axis=1)) + 1.5 # +1.5 Å for atom radius margin
2262
+ if max_extent > 0:
2263
+ w = max(self.width(), 500)
2264
+ h = max(self.height(), 450)
2265
+ self.base_scale = 0.40 * min(w, h) / max_extent
2266
+ self.request_render()
2267
+
2268
+ def reset_view(self):
2269
+ self._rot = self._default_view.copy()
2270
+ self._zoom = 1.0
2271
+ self._pan = np.array([0.0, 0.0])
2272
+ self.request_render()
2273
+
2274
+ def reset_xyz_axes(self):
2275
+ self._axes_ref = self._rot.copy()
2276
+ self._default_view = self._rot.copy()
2277
+ self.request_render()
2278
+
2279
+ def set_preset(self, rx, ry, rz):
2280
+ from molvector.render import rotation_matrix
2281
+ self._rot = rotation_matrix(math.radians(rx), math.radians(ry), math.radians(rz))
2282
+ self._zoom = 1.0
2283
+ self._pan = np.array([0.0, 0.0])
2284
+ self.request_render()
2285
+
2286
+ def set_principal_axis_preset(self, axis_index):
2287
+ import mol_strudel as strudel
2288
+ from molvector.render import ATOMIC_MASSES
2289
+ mol = self.molecule
2290
+ if not mol or len(mol.atoms) < 2:
2291
+ return
2292
+ coords = np.array([[a.x, a.y, a.z] for a in mol.atoms])
2293
+ masses = np.array([ATOMIC_MASSES.get(a.element, 0.0) for a in mol.atoms])
2294
+ _, eigvecs = strudel.diagonalize_I_tensor(coords, masses)
2295
+ Vt = eigvecs.T
2296
+ if axis_index == 0:
2297
+ cx, sx = math.cos(-math.pi/2), math.sin(-math.pi/2)
2298
+ Ry = np.array([[cx,0,sx],[0,1,0],[-sx,0,cx]])
2299
+ self._rot = Ry @ Vt
2300
+ elif axis_index == 1:
2301
+ cx, sx = math.cos(math.pi/2), math.sin(math.pi/2)
2302
+ Rx = np.array([[1,0,0],[0,cx,-sx],[0,sx,cx]])
2303
+ self._rot = Rx @ Vt
2304
+ else:
2305
+ self._rot = Vt
2306
+ self._zoom = 1.0
2307
+ self._pan = np.array([0.0, 0.0])
2308
+ self.request_render()
2309
+
2310
+ def edit_background_color(self):
2311
+ col = QColorDialog.getColor(QColor(self.background), self, "Background Colour")
2312
+ if col.isValid():
2313
+ self.background = col.name()
2314
+ self.request_render()
2315
+
2316
+ def request_render(self, delay_ms: int = 0):
2317
+ if not self._render_timer.isActive():
2318
+ self._render_timer.start(delay_ms)
2319
+
2320
+ def get_svg_bytes(self, export_mode: bool = False) -> bytes:
2321
+ return self._render_to_bytes(export_mode=export_mode)
2322
+
2323
+ # ── internal ─────────────────────────────────────────────────────────────
2324
+
2325
+ def _render_to_bytes(self, w=None, h=None, export_mode: bool = False) -> bytes:
2326
+ if self.molecule is None:
2327
+ return b""
2328
+ w = w or max(self.width(), 500)
2329
+ h = h or max(self.height(), 450)
2330
+
2331
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
2332
+ tmp = f.name
2333
+ try:
2334
+ render_molecule(
2335
+ self.molecule,
2336
+ rot_matrix_override=self._rot,
2337
+ pan_x=self._pan[0], pan_y=self._pan[1],
2338
+ canvas_w=w, canvas_h=h,
2339
+ scale=self.base_scale * self._zoom,
2340
+ atom_scale=self.atom_scale,
2341
+ bond_width_px=self.bond_width_px,
2342
+ bond_style=self.bond_style,
2343
+ bond_color=self.bond_color,
2344
+ background=self.background,
2345
+ color_overrides=self.color_overrides or None,
2346
+ atom_border_mode=self.atom_border_mode,
2347
+ atom_border_scale=self.atom_border_scale,
2348
+ atom_border_width=self.atom_border_width,
2349
+ lighting_intensity=self.lighting_intensity,
2350
+ light_position=self.light_position,
2351
+ roughness=self.roughness,
2352
+ show_axes=self.show_axes,
2353
+ show_principal_axes=self.show_principal_axes,
2354
+ axes_position=self.axes_position,
2355
+ principal_axes_position=self.principal_axes_position,
2356
+ axes_rot_override=self._rot @ np.linalg.inv(self._axes_ref),
2357
+ active_vectors=self.active_vectors,
2358
+ animation_phase=self.animation_phase,
2359
+ animation_amplitude=self.animation_amplitude,
2360
+ output_path=tmp,
2361
+ export_mode=export_mode,
2362
+ selected_indices=self.selected_atoms if not export_mode else None,
2363
+ vectors=self.arrows if self._show_vectors else None,
2364
+ )
2365
+ with open(tmp,"rb") as f:
2366
+ return f.read()
2367
+ finally:
2368
+ try: os.unlink(tmp)
2369
+ except OSError: pass
2370
+
2371
+ def _do_render(self):
2372
+ data = self._render_to_bytes()
2373
+ if data:
2374
+ self.load(QByteArray(data))
2375
+ # Overlay for bonding line
2376
+ if self.build_mode and self._bonding_from is not None and self._mouse_pos is not None:
2377
+ self.update() # Force paintEvent for overlay
2378
+
2379
+ def paintEvent(self, event):
2380
+ super().paintEvent(event)
2381
+ draw_sel = self.selection_mode and self._sel_rect is not None and self._sel_rect.isValid()
2382
+ draw_bond = self.build_mode and self._bonding_from is not None and self._mouse_pos is not None and self.molecule
2383
+ if not draw_sel and not draw_bond:
2384
+ return
2385
+
2386
+ p = QPainter(self)
2387
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
2388
+
2389
+ if draw_sel:
2390
+ p.fillRect(self._sel_rect, QColor(0, 120, 255, 30))
2391
+ p.setPen(QColor(0, 140, 255, 200))
2392
+ p.drawRect(self._sel_rect.toRect())
2393
+
2394
+ if draw_bond:
2395
+ p.setPen(QColor("#007bff"))
2396
+ atoms, _ = project_molecule(
2397
+ self.molecule, self._rot, self._pan[0], self._pan[1],
2398
+ self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
2399
+ )
2400
+ ax, ay, az, ar = atoms[self._bonding_from]
2401
+ p.drawLine(int(ax), int(ay), self._mouse_pos.x(), self._mouse_pos.y())
2402
+
2403
+ p.end()
2404
+
2405
+ # ── mouse ─────────────────────────────────────────────────────────────────
2406
+
2407
+ def _get_hit_atom(self, pos: QPoint) -> int | None:
2408
+ if not self.molecule: return None
2409
+ atoms, _ = project_molecule(
2410
+ self.molecule, self._rot, self._pan[0], self._pan[1],
2411
+ self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
2412
+ )
2413
+ for i, (ax, ay, az, ar) in enumerate(atoms):
2414
+ dist = math.hypot(pos.x() - ax, pos.y() - ay)
2415
+ if dist < max(ar, 10):
2416
+ return i
2417
+ return None
2418
+
2419
+ def _get_hit_bond(self, pos: QPoint) -> int | None:
2420
+ if not self.molecule: return None
2421
+ _, bonds = project_molecule(
2422
+ self.molecule, self._rot, self._pan[0], self._pan[1],
2423
+ self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
2424
+ )
2425
+ threshold = 8.0
2426
+ for ax, ay, bx, by, z_avg, idx in bonds:
2427
+ # Distance from point to segment
2428
+ L2 = (bx-ax)**2 + (by-ay)**2
2429
+ if L2 < 1e-6: continue
2430
+ t = max(0, min(1, ((pos.x()-ax)*(bx-ax) + (pos.y()-ay)*(by-ay)) / L2))
2431
+ proj_x = ax + t * (bx-ax)
2432
+ proj_y = ay + t * (by-ay)
2433
+ dist = math.hypot(pos.x() - proj_x, pos.y() - proj_y)
2434
+ if dist < threshold:
2435
+ return idx
2436
+ return None
2437
+
2438
+ def _unproject(self, pos: QPoint) -> np.ndarray:
2439
+ # returns 3D position in molecule local frame (relative to centroid)
2440
+ cx = self.width()/2 + self._pan[0]
2441
+ cy = self.height()/2 + self._pan[1]
2442
+ scale = self.base_scale * self._zoom
2443
+
2444
+ # Assume z_local = 0
2445
+ rp_x = (pos.x() - cx) / scale
2446
+ rp_y = (cy - pos.y()) / scale
2447
+ rp = np.array([rp_x, rp_y, 0.0])
2448
+ # local = rot^-1 @ rp
2449
+ local = np.linalg.inv(self._rot) @ rp
2450
+ return local
2451
+
2452
+ def mousePressEvent(self, event):
2453
+ self._update_cursor(event.position().toPoint(), event.modifiers())
2454
+ # Alt/Option+click drags an atom in build or selection mode
2455
+ mod = event.modifiers()
2456
+ if (self.build_mode or self.selection_mode) and event.button() == Qt.MouseButton.LeftButton and (mod & (Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier)):
2457
+ idx = self._get_hit_atom(event.position().toPoint())
2458
+ if idx is not None and self.molecule:
2459
+ self.requestHistorySave.emit()
2460
+ self._drag_atom_idx = idx
2461
+ self._drag_start = event.position().toPoint()
2462
+ self._drag_mode = "atom"
2463
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
2464
+ return
2465
+
2466
+ if self.selection_mode and event.button() == Qt.MouseButton.LeftButton:
2467
+ mod = event.modifiers()
2468
+ idx = self._get_hit_atom(event.position().toPoint())
2469
+ if idx is not None:
2470
+ if mod & Qt.KeyboardModifier.ShiftModifier:
2471
+ if idx in self.selected_atoms:
2472
+ self.selected_atoms.discard(idx)
2473
+ else:
2474
+ self.selected_atoms.add(idx)
2475
+ else:
2476
+ self.selected_atoms = {idx} if idx not in self.selected_atoms else set()
2477
+ self.request_render()
2478
+ return
2479
+ self.selected_atoms.clear()
2480
+ self._sel_drag_start = event.position().toPoint()
2481
+ self._sel_rect = QRectF()
2482
+ self.request_render()
2483
+ return
2484
+
2485
+ if self.build_mode and event.button() == Qt.MouseButton.LeftButton:
2486
+ idx = self._get_hit_atom(event.position().toPoint())
2487
+ if idx is not None:
2488
+ self._bonding_from = idx
2489
+ self._mouse_pos = event.position().toPoint()
2490
+ return
2491
+
2492
+ b_idx = self._get_hit_bond(event.position().toPoint())
2493
+ if b_idx is not None:
2494
+ self.requestHistorySave.emit()
2495
+ if self.bond_order_disabled:
2496
+ self.molecule.bonds[b_idx].order = 1
2497
+ else:
2498
+ self.molecule.bonds[b_idx].order = (self.molecule.bonds[b_idx].order % 3) + 1
2499
+
2500
+ if self.auto_adjust_h:
2501
+ at1, at2 = self.molecule.atoms[self.molecule.bonds[b_idx].i], self.molecule.atoms[self.molecule.bonds[b_idx].j]
2502
+ self._apply_auto_h(at1)
2503
+ self._apply_auto_h(at2)
2504
+ self._relax_h()
2505
+
2506
+ self.moleculeChanged.emit()
2507
+ self.request_render()
2508
+ return
2509
+
2510
+ # Add new atom if nothing hit
2511
+ if not self.molecule:
2512
+ self.molecule = Molecule("New Molecule", atoms=[])
2513
+
2514
+ self.requestHistorySave.emit()
2515
+ loc = self._unproject(event.position().toPoint())
2516
+ # Convert local to absolute (if we have a centroid)
2517
+ if self.molecule.atoms:
2518
+ pos_arr = np.array([[a.x, a.y, a.z] for a in self.molecule.atoms])
2519
+ centroid = pos_arr.mean(axis=0)
2520
+ abs_pos = loc + centroid
2521
+ else:
2522
+ abs_pos = loc
2523
+
2524
+ new_atom = Atom(self.build_element, abs_pos[0], abs_pos[1], abs_pos[2])
2525
+ self.molecule.atoms.append(new_atom)
2526
+ new_idx = len(self.molecule.atoms) - 1
2527
+
2528
+ if self.auto_adjust_h:
2529
+ self._apply_auto_h(self.molecule.atoms[new_idx])
2530
+ self._relax_h()
2531
+
2532
+ self.moleculeChanged.emit()
2533
+ self.request_render()
2534
+ return
2535
+
2536
+ if self.build_mode and event.button() == Qt.MouseButton.RightButton:
2537
+ idx = self._get_hit_atom(event.position().toPoint())
2538
+ if idx is not None:
2539
+ self.requestHistorySave.emit()
2540
+ # 1. Identify connected H atoms to remove
2541
+ h_to_remove = []
2542
+ neighbors_to_adjust = []
2543
+
2544
+ for b in self.molecule.bonds:
2545
+ if b.i == idx:
2546
+ nb_idx = b.j
2547
+ elif b.j == idx:
2548
+ nb_idx = b.i
2549
+ else:
2550
+ continue
2551
+
2552
+ nb_atom = self.molecule.atoms[nb_idx]
2553
+ if nb_atom.element == "H":
2554
+ # Only remove if it has no other bonds
2555
+ other_bonds = 0
2556
+ for b2 in self.molecule.bonds:
2557
+ if b2.i == nb_idx or b2.j == nb_idx: other_bonds += 1
2558
+ if other_bonds == 1:
2559
+ h_to_remove.append(nb_idx)
2560
+ else:
2561
+ neighbors_to_adjust.append(nb_atom)
2562
+
2563
+ # 2. Delete the atoms (highest index first to avoid shifts)
2564
+ all_to_del = sorted(list(set(h_to_remove + [idx])), reverse=True)
2565
+ for d_idx in all_to_del:
2566
+ self.molecule.atoms.pop(d_idx)
2567
+ new_bonds = []
2568
+ for b in self.molecule.bonds:
2569
+ if b.i == d_idx or b.j == d_idx: continue
2570
+ ni = b.i - 1 if b.i > d_idx else b.i
2571
+ nj = b.j - 1 if b.j > d_idx else b.j
2572
+ new_bonds.append(Bond(ni, nj, b.order))
2573
+ self.molecule.bonds = new_bonds
2574
+
2575
+ # 3. Adjust neighbors if auto_adjust_h is on
2576
+ if self.auto_adjust_h:
2577
+ for nb_atom in neighbors_to_adjust:
2578
+ self._apply_auto_h(nb_atom)
2579
+ self._relax_h()
2580
+
2581
+ self.moleculeChanged.emit()
2582
+ self.request_render()
2583
+ return
2584
+
2585
+ b_idx = self._get_hit_bond(event.position().toPoint())
2586
+ if b_idx is not None:
2587
+ self.requestHistorySave.emit()
2588
+ # Remove bond
2589
+ self.molecule.bonds.pop(b_idx)
2590
+ self.moleculeChanged.emit()
2591
+ self.request_render()
2592
+ return
2593
+
2594
+ if self.align_mode and event.button() == Qt.MouseButton.LeftButton:
2595
+ b_idx = self._get_hit_bond(event.position().toPoint())
2596
+ if b_idx is not None and self.molecule:
2597
+ bond = self.molecule.bonds[b_idx]
2598
+ a1 = self.molecule.atoms[bond.i]
2599
+ a2 = self.molecule.atoms[bond.j]
2600
+ v_local = np.array([a2.x - a1.x, a2.y - a1.y, a2.z - a1.z])
2601
+ norm = np.linalg.norm(v_local)
2602
+ if norm > 1e-10:
2603
+ v_local_hat = v_local / norm
2604
+ v_view = self._rot @ v_local_hat
2605
+ if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
2606
+ target = np.array([1.0, 0.0, 0.0])
2607
+ else:
2608
+ target = np.array([0.0, 1.0, 0.0])
2609
+ dot = np.dot(v_view, target)
2610
+ # Pick the closer direction to avoid large flips
2611
+ if dot < 0:
2612
+ target = -target
2613
+ dot = -dot
2614
+ if dot < 0.9999:
2615
+ sin_theta = np.linalg.norm(np.cross(v_view, target))
2616
+ if sin_theta > 1e-10:
2617
+ cos_theta = dot
2618
+ axis = np.cross(v_view, target) / sin_theta
2619
+ K = np.array([[0, -axis[2], axis[1]],
2620
+ [axis[2], 0, -axis[0]],
2621
+ [-axis[1], axis[0], 0]])
2622
+ R_corr = np.eye(3) + sin_theta * K + (1.0 - cos_theta) * (K @ K)
2623
+ self._rot = R_corr @ self._rot
2624
+ self.rotationChanged.emit()
2625
+ self.request_render()
2626
+ return
2627
+
2628
+ if self.align_mode and event.button() == Qt.MouseButton.RightButton:
2629
+ b_idx = self._get_hit_bond(event.position().toPoint())
2630
+ if b_idx is not None and self.molecule:
2631
+ bond = self.molecule.bonds[b_idx]
2632
+ a1 = self.molecule.atoms[bond.i]
2633
+ a2 = self.molecule.atoms[bond.j]
2634
+ v_local = np.array([a2.x - a1.x, a2.y - a1.y, a2.z - a1.z])
2635
+ n = np.linalg.norm(v_local)
2636
+ if n > 1e-10:
2637
+ self._align_axis = v_local / n
2638
+ self._drag_start = event.position().toPoint()
2639
+ self._drag_mode = "rotate_around_bond"
2640
+ self.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
2641
+ return
2642
+
2643
+ if event.button() == Qt.MouseButton.LeftButton:
2644
+ self._drag_start = event.position().toPoint()
2645
+ self._drag_mode = "rotate"
2646
+ self.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
2647
+ elif event.button() == Qt.MouseButton.RightButton:
2648
+ self._drag_start = event.position().toPoint()
2649
+ self._drag_mode = "pan"
2650
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
2651
+
2652
+ def _update_cursor(self, pos: QPoint, mods):
2653
+ has_atom = self._get_hit_atom(pos) is not None
2654
+ alt_held = bool(mods & (Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier))
2655
+
2656
+ if self.build_mode:
2657
+ if alt_held and has_atom:
2658
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
2659
+ else:
2660
+ self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
2661
+ elif self.selection_mode:
2662
+ if alt_held and has_atom:
2663
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
2664
+ elif has_atom:
2665
+ self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
2666
+ else:
2667
+ self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
2668
+ elif self.align_mode:
2669
+ if self._get_hit_bond(pos) is not None:
2670
+ self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
2671
+ else:
2672
+ self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
2673
+ else:
2674
+ self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
2675
+
2676
+ def mouseMoveEvent(self, event):
2677
+ self._mouse_pos = event.position().toPoint()
2678
+ self._update_cursor(event.position().toPoint(), event.modifiers())
2679
+ if self.build_mode and self._bonding_from is not None:
2680
+ self.request_render(delay_ms=16)
2681
+ return
2682
+
2683
+ if self.selection_mode and self._sel_drag_start is not None:
2684
+ cur = event.position().toPoint()
2685
+ self._sel_rect = QRectF(QPointF(self._sel_drag_start), QPointF(cur)).normalized()
2686
+ self.update()
2687
+ return
2688
+
2689
+ if self._drag_mode == "atom" and self._drag_atom_idx is not None and self.molecule:
2690
+ cur = event.position().toPoint()
2691
+ dx = cur.x() - self._drag_start.x()
2692
+ dy = cur.y() - self._drag_start.y()
2693
+ self._drag_start = cur
2694
+ scale = self.base_scale * self._zoom
2695
+ inv_rot = np.linalg.inv(self._rot)
2696
+ delta_3d = inv_rot @ np.array([dx / scale, -dy / scale, 0.0])
2697
+ # Move clicked atom
2698
+ atom = self.molecule.atoms[self._drag_atom_idx]
2699
+ atom.x += delta_3d[0]
2700
+ atom.y += delta_3d[1]
2701
+ atom.z += delta_3d[2]
2702
+ # Move all other selected atoms together
2703
+ if self._drag_atom_idx in self.selected_atoms:
2704
+ for i in self.selected_atoms:
2705
+ if i == self._drag_atom_idx:
2706
+ continue
2707
+ a = self.molecule.atoms[i]
2708
+ a.x += delta_3d[0]
2709
+ a.y += delta_3d[1]
2710
+ a.z += delta_3d[2]
2711
+ self.request_render(delay_ms=16)
2712
+ return
2713
+
2714
+ if self._drag_start is None or self.molecule is None:
2715
+ return
2716
+ cur = event.position().toPoint()
2717
+ dx = cur.x() - self._drag_start.x()
2718
+ dy = cur.y() - self._drag_start.y()
2719
+ self._drag_start = cur
2720
+
2721
+ if self._drag_mode == "rotate":
2722
+ sens = 0.008
2723
+ ax, ay = dy*sens, dx*sens
2724
+ cx,sx = math.cos(ax), math.sin(ax)
2725
+ cy,sy = math.cos(ay), math.sin(ay)
2726
+ Rx = np.array([[1,0,0],[0,cx,-sx],[0,sx,cx]])
2727
+ Ry = np.array([[cy,0,sy],[0,1,0],[-sy,0,cy]])
2728
+ self._rot = Rx @ Ry @ self._rot
2729
+ elif self._drag_mode == "pan":
2730
+ self._pan[0] += dx
2731
+ self._pan[1] += dy
2732
+ elif self._drag_mode == "rotate_around_bond" and self._align_axis is not None:
2733
+ sens = 0.008
2734
+ angle = dx * sens
2735
+ if abs(angle) > 1e-10:
2736
+ axis = self._rot @ self._align_axis
2737
+ ca = math.cos(angle); sa = math.sin(angle)
2738
+ K = np.array([[0, -axis[2], axis[1]],
2739
+ [axis[2], 0, -axis[0]],
2740
+ [-axis[1], axis[0], 0]])
2741
+ R = np.eye(3) + sa * K + (1.0 - ca) * (K @ K)
2742
+ self._rot = R @ self._rot
2743
+
2744
+ self.rotationChanged.emit()
2745
+ self.request_render(delay_ms=16)
2746
+
2747
+ def mouseReleaseEvent(self, event):
2748
+ self._update_cursor(event.position().toPoint(), event.modifiers())
2749
+ if self.selection_mode and self._sel_drag_start is not None:
2750
+ final_pt = event.position().toPoint()
2751
+ rect = QRectF(QPointF(self._sel_drag_start), QPointF(final_pt)).normalized()
2752
+ if rect.width() > 5 and rect.height() > 5 and self.molecule:
2753
+ atoms, _ = project_molecule(
2754
+ self.molecule, self._rot, self._pan[0], self._pan[1],
2755
+ self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
2756
+ )
2757
+ for i, (ax, ay, az, ar) in enumerate(atoms):
2758
+ if rect.contains(ax, ay):
2759
+ self.selected_atoms.add(i)
2760
+ self.request_render()
2761
+ self._sel_drag_start = None
2762
+ self._sel_rect = None
2763
+ self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
2764
+ self.update()
2765
+ return
2766
+
2767
+ if self.build_mode and self._bonding_from is not None:
2768
+ end_idx = self._get_hit_atom(event.position().toPoint())
2769
+ if end_idx is not None and end_idx == self._bonding_from:
2770
+ self.requestHistorySave.emit()
2771
+ self.molecule.atoms[self._bonding_from].element = self.build_element
2772
+ if self.auto_adjust_h:
2773
+ self._apply_auto_h(self.molecule.atoms[self._bonding_from])
2774
+ self._relax_h()
2775
+ self.moleculeChanged.emit()
2776
+ elif end_idx is not None and end_idx != self._bonding_from:
2777
+ self.requestHistorySave.emit()
2778
+ # Bond existing
2779
+ self.molecule.bonds.append(Bond(self._bonding_from, end_idx))
2780
+
2781
+ if self.auto_adjust_h:
2782
+ at1, at2 = self.molecule.atoms[self._bonding_from], self.molecule.atoms[end_idx]
2783
+ self._apply_auto_h(at1)
2784
+ self._apply_auto_h(at2)
2785
+ self._relax_h()
2786
+
2787
+ self.moleculeChanged.emit()
2788
+ elif end_idx is None:
2789
+ self.requestHistorySave.emit()
2790
+ # Add new atom and bond
2791
+ loc = self._unproject(event.position().toPoint())
2792
+ pos_arr = np.array([[a.x, a.y, a.z] for a in self.molecule.atoms])
2793
+ centroid = pos_arr.mean(axis=0)
2794
+ abs_pos = loc + centroid
2795
+ new_idx = len(self.molecule.atoms)
2796
+ self.molecule.atoms.append(Atom(self.build_element, abs_pos[0], abs_pos[1], abs_pos[2]))
2797
+ self.molecule.bonds.append(Bond(self._bonding_from, new_idx))
2798
+
2799
+ if self.auto_adjust_h:
2800
+ at1, at2 = self.molecule.atoms[self._bonding_from], self.molecule.atoms[new_idx]
2801
+ self._apply_auto_h(at1)
2802
+ self._apply_auto_h(at2)
2803
+ self._relax_h()
2804
+
2805
+ self.moleculeChanged.emit()
2806
+
2807
+ self._bonding_from = None
2808
+ self._mouse_pos = None
2809
+ self.request_render()
2810
+ return
2811
+
2812
+ was_atom_drag = self._drag_mode == "atom"
2813
+ self._drag_atom_idx = None
2814
+ self._drag_start = None
2815
+ self._drag_mode = "none"
2816
+ if was_atom_drag:
2817
+ if self.build_mode or self.selection_mode:
2818
+ self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
2819
+ else:
2820
+ self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
2821
+ elif self.align_mode:
2822
+ self._update_cursor(event.position().toPoint(), Qt.KeyboardModifier.NoModifier)
2823
+ else:
2824
+ self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
2825
+ self.request_render(delay_ms=0)
2826
+
2827
+ def keyPressEvent(self, event):
2828
+ if event.matches(QKeySequence.StandardKey.SelectAll):
2829
+ if self.molecule:
2830
+ self.selected_atoms = set(range(len(self.molecule.atoms)))
2831
+ self.request_render()
2832
+ return
2833
+ if event.key() == Qt.Key.Key_Escape:
2834
+ if self.selected_atoms:
2835
+ self.selected_atoms.clear()
2836
+ self.request_render()
2837
+ return
2838
+ mod = event.modifiers()
2839
+ if event.key() == Qt.Key.Key_A and (mod & Qt.KeyboardModifier.ShiftModifier) and (mod & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier)):
2840
+ if self.selected_atoms:
2841
+ self.selected_atoms.clear()
2842
+ self.request_render()
2843
+ return
2844
+ super().keyPressEvent(event)
2845
+
2846
+ def keyReleaseEvent(self, event):
2847
+ if self._mouse_pos and self._get_hit_atom(self._mouse_pos) is not None:
2848
+ self._update_cursor(self._mouse_pos, event.modifiers())
2849
+ super().keyReleaseEvent(event)
2850
+
2851
+ def _apply_auto_h(self, atom: Atom):
2852
+ valencies = {
2853
+ "H": 1, "C": 4, "N": 3, "O": 2, "F": 1,
2854
+ "P": 3, "S": 2, "Cl": 1, "Br": 1, "I": 1,
2855
+ "Si": 4, "B": 3
2856
+ }
2857
+ # Ideal X-H bond lengths per atom
2858
+ bond_lengths = {"H":0.74,"C":1.09,"N":1.01,"O":0.96,"F":0.92,
2859
+ "P":1.42,"S":1.34,"Cl":1.27,"Br":1.41,"I":1.61,
2860
+ "Si":1.48,"B":1.19}
2861
+ try:
2862
+ atom_idx = -1
2863
+ for i, a in enumerate(self.molecule.atoms):
2864
+ if a is atom:
2865
+ atom_idx = i
2866
+ break
2867
+ if atom_idx == -1: return
2868
+ except Exception: return
2869
+
2870
+ v = valencies.get(atom.element, 0)
2871
+ if v <= 0: return
2872
+
2873
+ # Collect existing neighbor indices and bond orders
2874
+ neighbors = []
2875
+ neighbor_orders = {}
2876
+ for b in self.molecule.bonds:
2877
+ if b.i == atom_idx:
2878
+ neighbors.append(b.j)
2879
+ neighbor_orders[b.j] = b.order
2880
+ elif b.j == atom_idx:
2881
+ neighbors.append(b.i)
2882
+ neighbor_orders[b.i] = b.order
2883
+
2884
+ # Separate current H (only bonded to this atom) from heavy neighbors
2885
+ h_indices = []
2886
+ heavy_indices = []
2887
+ heavy_order_sum = 0
2888
+ for nb_idx in set(neighbors):
2889
+ if self.molecule.atoms[nb_idx].element == "H":
2890
+ other_bonds = sum(1 for b in self.molecule.bonds if b.i == nb_idx or b.j == nb_idx)
2891
+ if other_bonds == 1:
2892
+ h_indices.append(nb_idx)
2893
+ else:
2894
+ heavy_indices.append(nb_idx)
2895
+ heavy_order_sum += neighbor_orders.get(nb_idx, 1)
2896
+ else:
2897
+ heavy_indices.append(nb_idx)
2898
+ heavy_order_sum += neighbor_orders.get(nb_idx, 1)
2899
+
2900
+ needed = max(0, v - heavy_order_sum)
2901
+
2902
+ # Remove excess H atoms
2903
+ if len(h_indices) > needed:
2904
+ to_del = sorted(h_indices[needed:], reverse=True)
2905
+ for idx in to_del:
2906
+ self.molecule.atoms.pop(idx)
2907
+ new_bonds = []
2908
+ for b in self.molecule.bonds:
2909
+ if b.i == idx or b.j == idx: continue
2910
+ ni = b.i - 1 if b.i > idx else b.i
2911
+ nj = b.j - 1 if b.j > idx else b.j
2912
+ new_bonds.append(Bond(ni, nj, b.order))
2913
+ self.molecule.bonds = new_bonds
2914
+ if atom_idx > idx: atom_idx -= 1
2915
+ return
2916
+
2917
+ if len(h_indices) >= needed:
2918
+ return
2919
+
2920
+ # Need to add (needed - len(h_indices)) H atoms
2921
+ n_add = needed - len(h_indices)
2922
+
2923
+ # Build existing bond direction vectors (from atom to heavy neighbors)
2924
+ atom_pos = np.array([atom.x, atom.y, atom.z])
2925
+ existing_vecs = []
2926
+ for nb_idx in heavy_indices:
2927
+ nb = self.molecule.atoms[nb_idx]
2928
+ d = np.array([nb.x - atom.x, nb.y - atom.y, nb.z - atom.z])
2929
+ nd = np.linalg.norm(d)
2930
+ if nd > 1e-4:
2931
+ existing_vecs.append(d / nd)
2932
+ else:
2933
+ existing_vecs.append(np.array([1.0, 0.0, 0.0]))
2934
+
2935
+ bl = bond_lengths.get(atom.element, 1.09)
2936
+ steric = len(heavy_indices) + needed # total bonded atoms (including H to add)
2937
+ if atom.element == "C":
2938
+ angle_by_s = {4: 109.47, 3: 120.0, 2: 180.0}
2939
+ elif atom.element == "O":
2940
+ angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
2941
+ elif atom.element == "N":
2942
+ angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
2943
+ else:
2944
+ angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
2945
+ angle_deg = angle_by_s.get(steric, 109.47)
2946
+ angle_rad = math.radians(angle_deg)
2947
+
2948
+ # Generate ideal geometry directions for the full steric number
2949
+ ideal_dirs = _ideal_directions(steric, angle_rad)
2950
+
2951
+ # Match existing bond directions to closest ideal directions
2952
+ used = set()
2953
+ h_dirs = []
2954
+ for vec in existing_vecs:
2955
+ best = -1
2956
+ best_dot = -2
2957
+ for j, d in enumerate(ideal_dirs):
2958
+ if j in used: continue
2959
+ dot = np.dot(vec, d)
2960
+ if dot > best_dot:
2961
+ best_dot = dot
2962
+ best = j
2963
+ used.add(best)
2964
+
2965
+ for j in range(len(ideal_dirs)):
2966
+ if j not in used:
2967
+ h_dirs.append(ideal_dirs[j])
2968
+
2969
+ # Add H atoms in the available ideal directions
2970
+ for i in range(min(n_add, len(h_dirs))):
2971
+ h_idx = len(self.molecule.atoms)
2972
+ off = h_dirs[i] * bl
2973
+ self.molecule.atoms.append(Atom("H", atom.x + off[0], atom.y + off[1], atom.z + off[2]))
2974
+ self.molecule.bonds.append(Bond(atom_idx, h_idx))
2975
+ # If more H needed than available ideal slots (edge case), place at uniform offsets
2976
+ for i in range(n_add - min(n_add, len(h_dirs))):
2977
+ h_idx = len(self.molecule.atoms)
2978
+ off = np.array([1.0, 0.0, 0.0])
2979
+ self.molecule.atoms.append(Atom("H", atom.x + off[0], atom.y + off[1], atom.z + off[2]))
2980
+ self.molecule.bonds.append(Bond(atom_idx, h_idx))
2981
+
2982
+ def _relax_h(self):
2983
+ """Geometrically re-place all H atoms (no FF solver needed)."""
2984
+ if not self.molecule:
2985
+ return
2986
+ parents_to_relax = []
2987
+ for atom_idx, atom in enumerate(self.molecule.atoms):
2988
+ if atom.element == "H":
2989
+ continue
2990
+ has_h = False
2991
+ for b in self.molecule.bonds:
2992
+ other = b.j if b.i == atom_idx else (b.i if b.j == atom_idx else -1)
2993
+ if 0 <= other < len(self.molecule.atoms) and self.molecule.atoms[other].element == "H":
2994
+ has_h = True
2995
+ break
2996
+ if has_h:
2997
+ parents_to_relax.append(atom)
2998
+ for atom in parents_to_relax:
2999
+ if atom in self.molecule.atoms:
3000
+ self._apply_auto_h(atom)
3001
+
3002
+ def _get_non_h_indices(self) -> List[int]:
3003
+ if not self.molecule: return []
3004
+ return [i for i, a in enumerate(self.molecule.atoms) if a.element != "H"]
3005
+
3006
+ def wheelEvent(self, event):
3007
+ if self.molecule is None:
3008
+ return
3009
+ factor = 1.12 if event.angleDelta().y() > 0 else (1/1.12)
3010
+ self._zoom = max(0.15, min(6.0, self._zoom * factor))
3011
+ self.rotationChanged.emit()
3012
+ self.request_render(delay_ms=16)
3013
+
3014
+ def resizeEvent(self, event):
3015
+ super().resizeEvent(event)
3016
+ self.request_render(delay_ms=80)
3017
+
3018
+
3019
+ # ─────────────────────────────────────────────────────────────────────────────
3020
+ # MAIN WINDOW
3021
+ # ─────────────────────────────────────────────────────────────────────────────
3022
+ class MainWindow(QMainWindow):
3023
+
3024
+ DEFAULT_SHORTCUTS = {
3025
+ "open": _mod("Ctrl+O"),
3026
+ "save_as": _mod("Ctrl+S"),
3027
+ "export_svg": _mod("Ctrl+Shift+S"),
3028
+ "export_view": _mod("Ctrl+E"),
3029
+ "quick_svg_export": _mod("Ctrl+Shift+X"),
3030
+ "quit": _mod("Ctrl+Q"),
3031
+ "settings": _mod("Ctrl+;"),
3032
+ "style": _mod("Ctrl+="),
3033
+ "shortcuts": "",
3034
+ "info": _mod("Ctrl+I"),
3035
+ "charge": "",
3036
+ "inchi": "",
3037
+ "selection_mode": "S",
3038
+ "build_mode": "B",
3039
+ "align_mode": "A",
3040
+ "reset_view": "R",
3041
+ "reset_xyz_axes": "Shift+R",
3042
+ "preset_top": "",
3043
+ "preset_bottom": "",
3044
+ "preset_front": "",
3045
+ "preset_back": "",
3046
+ "preset_left": "",
3047
+ "preset_right": "",
3048
+ "preset_perspective": "",
3049
+ "preset_bc_plane": "1",
3050
+ "preset_ac_plane": "2",
3051
+ "preset_ab_plane": "3",
3052
+ "overlay_settings": "",
3053
+ "g16_input": "",
3054
+ "rotational_constants": "",
3055
+ "dipole_moment": "",
3056
+ "calc_results": _mod("Ctrl+M"),
3057
+ "clean_molecule": _mod("Ctrl+L"),
3058
+ "undo": _mod("Ctrl+Z"),
3059
+ "redo": _mod("Ctrl+Shift+Z"),
3060
+ "optimize_settings": "",
3061
+ "bond_order": "",
3062
+ "open_test": _mod("Ctrl+Shift+T"),
3063
+ "open_test_folder": "",
3064
+ "about": "",
3065
+ }
3066
+
3067
+ ALT_SHORTCUT_DEFAULTS = {
3068
+ "redo": _mod("Ctrl+Y"),
3069
+ }
3070
+
3071
+ LAST_MOLECULE_FILE = os.path.join(
3072
+ os.path.dirname(os.path.abspath(__file__)), "molvector_last_molecule.txt"
3073
+ )
3074
+
3075
+ def __init__(self):
3076
+ super().__init__()
3077
+ self.setWindowTitle("Molvector — Molecule Viewer")
3078
+ self.resize(1080, 720)
3079
+ self._current_theme = "light"
3080
+ self._apply_theme("light")
3081
+ self._current_source = "None"
3082
+ self._current_path = ""
3083
+ self._color_overrides: dict = {} # elem -> hex
3084
+
3085
+ # Animation timer
3086
+ self._anim_timer = QTimer(self)
3087
+ self._anim_timer.setInterval(40) # ~25 FPS
3088
+ self._anim_timer.timeout.connect(self._on_anim_step)
3089
+ self._anim_phase = 0.0
3090
+
3091
+ # FF Parameters
3092
+ self._ff_max_steps = 500
3093
+ self._ff_tol = 0.01
3094
+
3095
+ # Undo/Redo history
3096
+ self._history = [] # list of deepcopied Molecule
3097
+ self._redo_stack = []
3098
+ self._max_history = 50
3099
+
3100
+ # Toolbar icon size for mode buttons
3101
+ self._mode_icon_size = QSize(22, 22)
3102
+
3103
+ self._shortcut_actions: dict = {}
3104
+
3105
+ self._build_central()
3106
+ self._build_menubar()
3107
+ self._build_toolbar()
3108
+ self._setup_builder_toolbar()
3109
+ self._set_build_options_visible(False)
3110
+ self._build_statusbar()
3111
+ self._load_appearance_config()
3112
+ self._apply_shortcut_config()
3113
+ self._maybe_restore_molecule()
3114
+ self._show_placeholder()
3115
+ self.setAcceptDrops(True)
3116
+
3117
+ # ── menu bar ─────────────────────────────────────────────────────────────
3118
+
3119
+ def _build_menubar(self):
3120
+ mb = self.menuBar()
3121
+
3122
+ # ── File ──
3123
+ file_menu = mb.addMenu("&File")
3124
+
3125
+ act_open = QAction("&Open…", self)
3126
+ act_open.setShortcut(_mod("Ctrl+O"))
3127
+ act_open.triggered.connect(self._open_file)
3128
+ file_menu.addAction(act_open)
3129
+ self._shortcut_actions["open"] = act_open
3130
+
3131
+ act_save_as = QAction("Save &As…", self)
3132
+ act_save_as.setShortcut(_mod("Ctrl+S"))
3133
+ act_save_as.triggered.connect(self._save_as)
3134
+ file_menu.addAction(act_save_as)
3135
+ self._shortcut_actions["save_as"] = act_save_as
3136
+
3137
+ file_menu.addSeparator()
3138
+
3139
+ act_save_svg = QAction("Export as &SVG…", self)
3140
+ act_save_svg.setShortcut(_mod("Ctrl+Shift+S"))
3141
+ act_save_svg.triggered.connect(self._save_svg)
3142
+ file_menu.addAction(act_save_svg)
3143
+ self._shortcut_actions["export_svg"] = act_save_svg
3144
+
3145
+ act_export = QAction("&Export View…", self)
3146
+ act_export.setShortcut(_mod("Ctrl+E"))
3147
+ act_export.triggered.connect(self._export_view)
3148
+ file_menu.addAction(act_export)
3149
+ self._shortcut_actions["export_view"] = act_export
3150
+
3151
+ act_quick_svg = QAction("Quick SVG E&xport…", self)
3152
+ act_quick_svg.setShortcut(_mod("Ctrl+Shift+X"))
3153
+ act_quick_svg.triggered.connect(self._quick_svg_export)
3154
+ file_menu.addAction(act_quick_svg)
3155
+ self._shortcut_actions["quick_svg_export"] = act_quick_svg
3156
+
3157
+ file_menu.addSeparator()
3158
+
3159
+ act_quit = QAction("&Quit", self)
3160
+ act_quit.setShortcut(_mod("Ctrl+Q"))
3161
+ act_quit.triggered.connect(self.close)
3162
+ file_menu.addAction(act_quit)
3163
+ self._shortcut_actions["quit"] = act_quit
3164
+
3165
+ # ── Edit ──
3166
+ edit_menu = mb.addMenu("&Edit")
3167
+
3168
+ act_settings = QAction("&Settings…", self)
3169
+ act_settings.setShortcut(_mod("Ctrl+;"))
3170
+ act_settings.triggered.connect(self._edit_settings)
3171
+ edit_menu.addAction(act_settings)
3172
+ self._shortcut_actions["settings"] = act_settings
3173
+
3174
+ act_style = QAction("&Style…", self)
3175
+ act_style.setShortcut(_mod("Ctrl+="))
3176
+ act_style.triggered.connect(self._edit_style)
3177
+ edit_menu.addAction(act_style)
3178
+ self._shortcut_actions["style"] = act_style
3179
+
3180
+ act_shortcuts = QAction("&Shortcuts…", self)
3181
+ act_shortcuts.triggered.connect(self._edit_shortcuts)
3182
+ edit_menu.addAction(act_shortcuts)
3183
+ self._shortcut_actions["shortcuts"] = act_shortcuts
3184
+
3185
+ edit_menu.addSeparator()
3186
+
3187
+ act_info = QAction("&Info…", self)
3188
+ act_info.setShortcut(_mod("Ctrl+I"))
3189
+ act_info.triggered.connect(self._show_molecule_info)
3190
+ edit_menu.addAction(act_info)
3191
+ self._shortcut_actions["info"] = act_info
3192
+
3193
+ act_charge = QAction("Edit &Charge…", self)
3194
+ act_charge.triggered.connect(self._edit_charge)
3195
+ edit_menu.addAction(act_charge)
3196
+ self._shortcut_actions["charge"] = act_charge
3197
+
3198
+ act_inchi = QAction("&InChI…", self)
3199
+ act_inchi.triggered.connect(self._show_inchi)
3200
+ edit_menu.addAction(act_inchi)
3201
+ self._shortcut_actions["inchi"] = act_inchi
3202
+
3203
+ edit_menu.addSeparator()
3204
+
3205
+ act_select_toggle = QAction("Selection Mode", self)
3206
+ act_select_toggle.setCheckable(True)
3207
+ act_select_toggle.setShortcut("S")
3208
+ act_select_toggle.triggered.connect(self._toggle_selection_mode)
3209
+ edit_menu.addAction(act_select_toggle)
3210
+ self._act_select_toggle = act_select_toggle
3211
+ self._shortcut_actions["selection_mode"] = act_select_toggle
3212
+
3213
+ act_toggle = QAction("Build Mode", self)
3214
+ act_toggle.setCheckable(True)
3215
+ act_toggle.setShortcut("B")
3216
+ act_toggle.triggered.connect(self._toggle_build_mode)
3217
+ edit_menu.addAction(act_toggle)
3218
+ self._act_build_toggle = act_toggle
3219
+ self._shortcut_actions["build_mode"] = act_toggle
3220
+
3221
+ act_align_toggle = QAction("Align Mode", self)
3222
+ act_align_toggle.setCheckable(True)
3223
+ act_align_toggle.setShortcut("A")
3224
+ act_align_toggle.triggered.connect(self._toggle_align_mode)
3225
+ edit_menu.addAction(act_align_toggle)
3226
+ self._act_align_toggle = act_align_toggle
3227
+ self._shortcut_actions["align_mode"] = act_align_toggle
3228
+
3229
+ # ── View ──
3230
+ view_menu = mb.addMenu("&View")
3231
+ act_reset_view = QAction("&Reset View", self)
3232
+ act_reset_view.setShortcut("R")
3233
+ act_reset_view.triggered.connect(self._reset_view)
3234
+ view_menu.addAction(act_reset_view)
3235
+ self._shortcut_actions["reset_view"] = act_reset_view
3236
+
3237
+ act_reset_xyz = QAction("Set XYZ &Axes", self)
3238
+ act_reset_xyz.setShortcut("Shift+R")
3239
+ act_reset_xyz.triggered.connect(lambda: self._canvas.reset_xyz_axes())
3240
+ view_menu.addAction(act_reset_xyz)
3241
+ self._shortcut_actions["reset_xyz_axes"] = act_reset_xyz
3242
+
3243
+ view_menu.addSeparator()
3244
+
3245
+ presets_menu = view_menu.addMenu("&Preset Orientation")
3246
+ preset_names = {
3247
+ "preset_top": ("Top", ( 5, 0, 0)),
3248
+ "preset_bottom": ("Bottom", (175, 0, 0)),
3249
+ "preset_front": ("Front", ( 0, 0, 0)),
3250
+ "preset_back": ("Back", ( 0,180, 0)),
3251
+ "preset_left": ("Left", ( 0,-90, 0)),
3252
+ "preset_right": ("Right", ( 0, 90, 0)),
3253
+ "preset_perspective": ("Perspective", (55, 20, 15)),
3254
+ }
3255
+ for key, (label, rot) in preset_names.items():
3256
+ a = QAction(label, self)
3257
+ a.triggered.connect(lambda _, r=rot: self._canvas.set_preset(*r))
3258
+ presets_menu.addAction(a)
3259
+ self._shortcut_actions[key] = a
3260
+
3261
+ presets_menu.addSeparator()
3262
+ for idx, label in [(0, "BC plane"), (1, "AC plane"), (2, "AB plane")]:
3263
+ a = QAction(label, self)
3264
+ a.setShortcut(str(idx + 1))
3265
+ a.triggered.connect(lambda _, i=idx: self._canvas.set_principal_axis_preset(i))
3266
+ presets_menu.addAction(a)
3267
+ key = ["preset_bc_plane", "preset_ac_plane", "preset_ab_plane"][idx]
3268
+ self._shortcut_actions[key] = a
3269
+
3270
+ view_menu.addSeparator()
3271
+ act_overlay = QAction("Overlay Settings", self)
3272
+ act_overlay.triggered.connect(self._edit_overlay)
3273
+ view_menu.addAction(act_overlay)
3274
+ self._shortcut_actions["overlay_settings"] = act_overlay
3275
+
3276
+ # ── Calculations ──
3277
+ self._menu_calc = mb.addMenu("&Calculations")
3278
+ act_g16 = QAction("Generate G16 Input…", self)
3279
+ act_g16.triggered.connect(self._generate_g16_input)
3280
+ self._menu_calc.addAction(act_g16)
3281
+ self._shortcut_actions["g16_input"] = act_g16
3282
+ self._menu_calc.setEnabled(True)
3283
+
3284
+ self._menu_calc.addSeparator()
3285
+ act_rotconstant = QAction("Calculate Rotational Constants", self)
3286
+ act_rotconstant.triggered.connect(self._calculate_rotational_constants)
3287
+ self._menu_calc.addAction(act_rotconstant)
3288
+ self._shortcut_actions["rotational_constants"] = act_rotconstant
3289
+
3290
+ act_dipole = QAction("Calculate Dipole Moment", self)
3291
+ act_dipole.triggered.connect(self._calculate_dipole_moment)
3292
+ self._menu_calc.addAction(act_dipole)
3293
+ self._shortcut_actions["dipole_moment"] = act_dipole
3294
+
3295
+ # ── Build ──
3296
+ self._menu_build = mb.addMenu("&Build")
3297
+
3298
+ act_clean_m = QAction("Clean Molecule", self)
3299
+ act_clean_m.setShortcut(_mod("Ctrl+L"))
3300
+ act_clean_m.triggered.connect(self._clean_molecule)
3301
+ self._menu_build.addAction(act_clean_m)
3302
+ self._shortcut_actions["clean_molecule"] = act_clean_m
3303
+
3304
+ act_undo = QAction("Undo", self)
3305
+ act_undo.setShortcut(_mod("Ctrl+Z"))
3306
+ act_undo.triggered.connect(self._undo)
3307
+ self._menu_build.addAction(act_undo)
3308
+ self._shortcut_actions["undo"] = act_undo
3309
+
3310
+ act_redo = QAction("Redo", self)
3311
+ act_redo.setShortcuts([QKeySequence(_mod("Ctrl+Shift+Z")), QKeySequence(_mod("Ctrl+Y"))])
3312
+ act_redo.triggered.connect(self._redo)
3313
+ self._menu_build.addAction(act_redo)
3314
+ self._shortcut_actions["redo"] = act_redo
3315
+
3316
+ act_ff = QAction("Optimize Settings…", self)
3317
+ act_ff.triggered.connect(self._edit_ff_settings)
3318
+ self._menu_build.addAction(act_ff)
3319
+ self._shortcut_actions["optimize_settings"] = act_ff
3320
+
3321
+ self._menu_build.addSeparator()
3322
+
3323
+ self._act_bond_order = QAction("Disable Bond Order", self)
3324
+ self._act_bond_order.setCheckable(True)
3325
+ self._act_bond_order.triggered.connect(self._toggle_bond_order)
3326
+ self._menu_build.addAction(self._act_bond_order)
3327
+ self._shortcut_actions["bond_order"] = self._act_bond_order
3328
+
3329
+ act_paint = QAction("Paint Selected Atoms…", self)
3330
+ act_paint.triggered.connect(self._paint_selected_atoms)
3331
+ self._menu_build.addAction(act_paint)
3332
+ self._shortcut_actions["paint_atoms"] = act_paint
3333
+
3334
+ act_clear_paint = QAction("Clear Paint", self)
3335
+ act_clear_paint.triggered.connect(self._clear_paint)
3336
+ self._menu_build.addAction(act_clear_paint)
3337
+ self._shortcut_actions["clear_paint"] = act_clear_paint
3338
+
3339
+ # ── Help ──
3340
+ help_menu = mb.addMenu("&Help")
3341
+ act_open_test = QAction("Open Test Molecule", self)
3342
+ act_open_test.setShortcut(_mod("Ctrl+Shift+T"))
3343
+ act_open_test.triggered.connect(self._open_test_molecule)
3344
+ help_menu.addAction(act_open_test)
3345
+ self._shortcut_actions["open_test"] = act_open_test
3346
+
3347
+ act_open_test_folder = QAction("Open Test Files Folder", self)
3348
+ act_open_test_folder.triggered.connect(
3349
+ lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(
3350
+ os.path.join(os.path.dirname(os.path.dirname(__file__)), "test_files"))))
3351
+ help_menu.addAction(act_open_test_folder)
3352
+ self._shortcut_actions["open_test_folder"] = act_open_test_folder
3353
+
3354
+ help_menu.addSeparator()
3355
+
3356
+ act_about = QAction("&About Molvector", self)
3357
+ act_about.triggered.connect(self._show_about)
3358
+ help_menu.addAction(act_about)
3359
+ self._shortcut_actions["about"] = act_about
3360
+
3361
+ def _setup_builder_toolbar(self):
3362
+ assets_dir = os.path.join(os.path.dirname(__file__), "assets", "icons")
3363
+ self._build_toolbar_obj = QToolBar("Builder")
3364
+ self._build_toolbar_obj.setObjectName("builderToolbar")
3365
+ self._build_toolbar_obj.setIconSize(QSize(22, 22))
3366
+ self._build_toolbar_obj.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
3367
+ self.addToolBar(self._build_toolbar_obj)
3368
+
3369
+ icon_color = '#ccd6f6' if self._current_theme == 'dark' else '#000000'
3370
+
3371
+ # Selection tool
3372
+ select_path = os.path.join(assets_dir, "icon_select.svg")
3373
+ self._act_select_btn = QAction(load_colored_icon(select_path, icon_color), "Selection", self)
3374
+ self._act_select_btn.setCheckable(True)
3375
+ self._act_select_btn.setToolTip("Selection tool — click or drag to select atoms")
3376
+ self._act_select_btn.triggered.connect(self._toggle_selection_mode)
3377
+ self._build_toolbar_obj.addAction(self._act_select_btn)
3378
+
3379
+ # Build tool
3380
+ draw_path = os.path.join(assets_dir, "icon_draw.svg")
3381
+ self._act_build_btn = QAction(load_colored_icon(draw_path, icon_color), "Build Mode", self)
3382
+ self._act_build_btn.setCheckable(True)
3383
+ self._act_build_btn.setToolTip("Build mode — add / bond atoms")
3384
+ self._act_build_btn.triggered.connect(self._toggle_build_mode)
3385
+ self._build_toolbar_obj.addAction(self._act_build_btn)
3386
+
3387
+ # Align tool
3388
+ align_path = os.path.join(assets_dir, "icon_align.svg")
3389
+ self._act_align_btn = QAction(load_colored_icon(align_path, icon_color), "Align", self)
3390
+ self._act_align_btn.setCheckable(True)
3391
+ self._act_align_btn.setToolTip("Align tool — click a bond to align it vertically")
3392
+ self._act_align_btn.triggered.connect(self._toggle_align_mode)
3393
+ self._build_toolbar_obj.addAction(self._act_align_btn)
3394
+
3395
+ self._sep_build1 = self._build_toolbar_obj.addSeparator()
3396
+ self._act_lbl_elem = self._build_toolbar_obj.addWidget(QLabel(" Element: "))
3397
+ self._elem_combo = QComboBox()
3398
+ self._elem_combo.setMinimumContentsLength(3)
3399
+ self._elem_combo.setMaximumWidth(60)
3400
+ self._elem_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
3401
+ self._common_elements = ["H", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"]
3402
+ self._elem_combo.addItems(self._common_elements + ["..."])
3403
+ self._elem_combo.setCurrentText("C")
3404
+ self._elem_combo.currentTextChanged.connect(self._on_build_elem_change)
3405
+ self._act_elem_combo = self._build_toolbar_obj.addWidget(self._elem_combo)
3406
+
3407
+ self._sep_build2 = self._build_toolbar_obj.addSeparator()
3408
+ self._auto_h_check = QCheckBox("Auto adjust H")
3409
+ self._auto_h_check.setChecked(False)
3410
+ self._auto_h_check.toggled.connect(self._on_auto_h_toggle)
3411
+ self._act_auto_h = self._build_toolbar_obj.addWidget(self._auto_h_check)
3412
+
3413
+ self._sep_build3 = self._build_toolbar_obj.addSeparator()
3414
+ self._act_clean = QAction("Clean", self)
3415
+ self._act_clean.setToolTip("Rapidly optimize geometry (Force Field)")
3416
+ self._act_clean.triggered.connect(self._clean_molecule)
3417
+ self._build_toolbar_obj.addAction(self._act_clean)
3418
+
3419
+ def _set_build_options_visible(self, visible: bool):
3420
+ self._sep_build1.setVisible(visible)
3421
+ self._act_lbl_elem.setVisible(visible)
3422
+ self._act_elem_combo.setVisible(visible)
3423
+ self._sep_build2.setVisible(visible)
3424
+ self._act_auto_h.setVisible(visible)
3425
+ self._sep_build3.setVisible(visible)
3426
+ self._act_clean.setVisible(visible)
3427
+ self._build_toolbar_obj.updateGeometry()
3428
+
3429
+ # ── toolbar (only the most frequent actions) ─────────────────────────────
3430
+
3431
+ def _build_toolbar(self):
3432
+ tb = self.addToolBar("Main")
3433
+ tb.setMovable(False)
3434
+ tb.setIconSize(QSize(16,16))
3435
+ tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
3436
+
3437
+ def tb_action(label, slot, shortcut=None, tooltip=None):
3438
+ a = QAction(label, self)
3439
+ if shortcut: a.setShortcut(shortcut)
3440
+ if tooltip: a.setToolTip(tooltip)
3441
+ a.triggered.connect(slot)
3442
+ tb.addAction(a)
3443
+ return a
3444
+
3445
+ tb_action("Open", self._open_file, _mod("Ctrl+O"), "Open molecule file")
3446
+ tb_action("Save", self._save_as, _mod("Ctrl+S"), "Save molecule file")
3447
+ tb_action("Export view", self._export_view, _mod("Ctrl+E"), "Export view as PDF/PNG/SVG")
3448
+ tb.addSeparator()
3449
+ tb_action("Reset view", self._reset_view)
3450
+ tb_action("Clear All", self._clear_molecule)
3451
+ tb.addSeparator()
3452
+
3453
+ # Zoom readout
3454
+ self._zoom_lbl = QLabel("100%")
3455
+ self._zoom_lbl.setFixedWidth(46)
3456
+ self._zoom_lbl.setObjectName("zoom_label")
3457
+ # Style this in get_stylesheet instead of here
3458
+ tb.addWidget(self._zoom_lbl)
3459
+
3460
+ # Zoom slider
3461
+ self._zoom_slider = QSlider(Qt.Orientation.Horizontal)
3462
+ self._zoom_slider.setRange(15, 600)
3463
+ self._zoom_slider.setValue(100)
3464
+ self._zoom_slider.setFixedWidth(120)
3465
+ self._zoom_slider.setToolTip("Zoom level (%)")
3466
+ self._zoom_slider.valueChanged.connect(self._on_zoom_change)
3467
+ self._zoom_slider.installEventFilter(self)
3468
+ tb.addWidget(self._zoom_slider)
3469
+
3470
+ # ── central layout ────────────────────────────────────────────────────────
3471
+
3472
+ def _build_central(self):
3473
+ central = QWidget()
3474
+ self.setCentralWidget(central)
3475
+ root = QHBoxLayout(central)
3476
+ root.setContentsMargins(0,0,0,0)
3477
+ root.setSpacing(0)
3478
+
3479
+ # Sidebar
3480
+ sidebar = QWidget()
3481
+ sidebar.setObjectName("sidebar")
3482
+ sidebar.setFixedWidth(172)
3483
+ sl = QVBoxLayout(sidebar)
3484
+ sl.setContentsMargins(10,14,10,14)
3485
+ sl.setSpacing(10)
3486
+
3487
+ # Molecule info
3488
+ info = QGroupBox("Molecule")
3489
+ il = QVBoxLayout(info)
3490
+ self._lbl_name = QLabel("—")
3491
+ self._lbl_name.setWordWrap(True)
3492
+ self._lbl_name.setObjectName("mol_name")
3493
+ self._lbl_charge = QLabel("Charge: —")
3494
+ self._lbl_atoms = QLabel("Atoms: —")
3495
+ self._lbl_bonds = QLabel("Bonds: —")
3496
+ self._lbl_mass = QLabel("Mass: —")
3497
+ self._lbl_src = QLabel("Source: —")
3498
+ self._lbl_src.setObjectName("dim")
3499
+ self._lbl_src.setStyleSheet("font-size:10px;")
3500
+ for w in (self._lbl_name, self._lbl_atoms, self._lbl_bonds, self._lbl_mass, self._lbl_charge, self._lbl_src):
3501
+ il.addWidget(w)
3502
+ sl.addWidget(info)
3503
+
3504
+ # Calculations info
3505
+ self._calc_group = QGroupBox("Calculations")
3506
+ cl = QVBoxLayout(self._calc_group)
3507
+ self._lbl_energy = QLabel("Energy: —")
3508
+ self._lbl_vib = QLabel("Vibrations: —")
3509
+ self._lbl_td = QLabel("States: —")
3510
+ for w in (self._lbl_energy, self._lbl_vib, self._lbl_td):
3511
+ cl.addWidget(w)
3512
+ self._btn_view_calc = QPushButton("View Results…")
3513
+ self._btn_view_calc.clicked.connect(self._show_calculations_dialog)
3514
+ cl.addWidget(self._btn_view_calc)
3515
+ sl.addWidget(self._calc_group)
3516
+ self._calc_group.hide()
3517
+
3518
+ # Legend
3519
+ self._legend = LegendPanel()
3520
+ self._legend.elementColorChanged.connect(self._on_legend_color_changed)
3521
+ self._legend.atomColorChanged.connect(self._on_atom_color_changed)
3522
+ sl.addWidget(self._legend)
3523
+ sl.addStretch()
3524
+
3525
+ # Hint
3526
+ self._hint = QLabel("Drag rotate\nRight-drag pan\nScroll zoom")
3527
+ self._hint.setObjectName("hint")
3528
+ self._hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
3529
+ sl.addWidget(self._hint)
3530
+
3531
+ root.addWidget(sidebar)
3532
+
3533
+ # Canvas
3534
+ self._canvas = MoleculeCanvas()
3535
+ self._canvas.rotationChanged.connect(self._on_rotation_changed)
3536
+ self._canvas.fileDropped.connect(self._load_and_display)
3537
+ self._canvas.moleculeChanged.connect(self._on_structure_changed)
3538
+ self._canvas.requestHistorySave.connect(self._save_history)
3539
+ root.addWidget(self._canvas, 1)
3540
+
3541
+ # ── status bar ────────────────────────────────────────────────────────────
3542
+
3543
+ def _build_statusbar(self):
3544
+ self._status = QStatusBar()
3545
+ self.setStatusBar(self._status)
3546
+ self._status.showMessage(
3547
+ "No file loaded — use File → Open to load an .xyz, .gjf, .com or .log file."
3548
+ )
3549
+
3550
+ # ── placeholder ───────────────────────────────────────────────────────────
3551
+
3552
+ def _show_placeholder(self):
3553
+ bg = THEMES[self._current_theme]["CANVAS"]
3554
+ fg = THEMES[self._current_theme]["FG_DIM"]
3555
+ svg = (
3556
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="600" height="450" viewBox="0 0 600 450">'
3557
+ f'<rect width="600" height="450" fill="{bg}"/>'
3558
+ f'<text x="300" y="210" text-anchor="middle" font-family="monospace" font-size="15" fill="{fg}">'
3559
+ 'Open a molecule file to begin'
3560
+ '</text>'
3561
+ '</svg>'
3562
+ ).encode("ascii")
3563
+ self._canvas.load(QByteArray(svg))
3564
+
3565
+ # ── drag and drop ─────────────────────────────────────────────────────────
3566
+
3567
+ def dragEnterEvent(self, event):
3568
+ if event.mimeData().hasUrls():
3569
+ event.acceptProposedAction()
3570
+ else:
3571
+ event.ignore()
3572
+
3573
+ def dragMoveEvent(self, event):
3574
+ if event.mimeData().hasUrls():
3575
+ event.acceptProposedAction()
3576
+ else:
3577
+ event.ignore()
3578
+
3579
+ def dropEvent(self, event):
3580
+ urls = event.mimeData().urls()
3581
+ if urls and urls[0].isLocalFile():
3582
+ path = urls[0].toLocalFile()
3583
+ self._load_and_display(path)
3584
+ event.acceptProposedAction()
3585
+ else:
3586
+ event.ignore()
3587
+
3588
+ # ── file open / save ──────────────────────────────────────────────────────
3589
+
3590
+ def _load_mol_from_path(self, path: str):
3591
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
3592
+ text = f.read()
3593
+ base = os.path.basename(path)
3594
+ name = os.path.splitext(base)[0]
3595
+
3596
+ mol = None
3597
+ src = ""
3598
+ errors = []
3599
+
3600
+ # Try all parsers regardless of file extension
3601
+ parsers = [
3602
+ ("Gaussian log", lambda t: parse_gaussian_log(t)),
3603
+ ("XYZ", lambda t: parse_xyz(t, name=name)),
3604
+ ("PDB", lambda t: parse_pdb(t, name=name)),
3605
+ ("MDL Molfile", lambda t: parse_mol(t, name=name)),
3606
+ ("Gaussian input", lambda t: parse_gaussian(t))
3607
+ ]
3608
+
3609
+ for source_name, parser in parsers:
3610
+ try:
3611
+ mol = parser(text)
3612
+ if mol and mol.atoms:
3613
+ src = source_name
3614
+ break
3615
+ except Exception as e:
3616
+ errors.append(f"{source_name}: {e}")
3617
+
3618
+ if mol is None or not mol.atoms:
3619
+ err_msg = "\n".join(errors)
3620
+ raise ValueError(f"Could not parse file format.\nAttempts:\n{err_msg}")
3621
+
3622
+ infer_bonds(mol)
3623
+ return mol, src
3624
+
3625
+ def _load_and_display(self, path: str):
3626
+ """Parse a file and update all UI elements. Used by drag-and-drop and Open."""
3627
+ try:
3628
+ mol, src = self._load_mol_from_path(path)
3629
+ self._color_overrides = {}
3630
+ self._canvas.color_overrides = {}
3631
+ self._canvas.load_molecule(mol)
3632
+ self._legend.update_for(mol, {})
3633
+ self._current_source = src
3634
+ self._current_path = path
3635
+ self._update_info_panel(mol)
3636
+ self._update_calculations_menu(mol)
3637
+ except Exception as e:
3638
+ QMessageBox.critical(self, "Error loading file",
3639
+ f"{type(e).__name__}: {e}")
3640
+
3641
+ def _update_calculations_menu(self, mol: Molecule):
3642
+ # Keep G16 Input, separator, Rotational Constants, Dipole Moment
3643
+ while len(self._menu_calc.actions()) > 4:
3644
+ self._menu_calc.removeAction(self._menu_calc.actions()[-1])
3645
+
3646
+ if mol and (mol.vibrational_modes or mol.excited_states):
3647
+ self._menu_calc.addSeparator()
3648
+ act_results = QAction("Calculation Results…", self)
3649
+ overrides, alt_overrides = self._load_shortcut_overrides()
3650
+ ks = overrides.get("calc_results", _mod("Ctrl+M"))
3651
+ act_results.setShortcut(ks)
3652
+ alt_ks = alt_overrides.get("calc_results", "")
3653
+ if alt_ks and ks:
3654
+ act_results.setShortcuts([QKeySequence(ks), QKeySequence(alt_ks)])
3655
+ act_results.triggered.connect(self._show_calculations_dialog)
3656
+ self._menu_calc.addAction(act_results)
3657
+ self._shortcut_actions["calc_results"] = act_results
3658
+
3659
+ def _generate_g16_input(self):
3660
+ mol = self._canvas.molecule
3661
+ if not mol or not mol.atoms:
3662
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
3663
+ return
3664
+ dlg = G16InputDialog(mol, parent=self)
3665
+ dlg.exec()
3666
+
3667
+ def _show_calculations_dialog(self):
3668
+ mol = self._canvas.molecule
3669
+ if not mol: return
3670
+ dlg = CalculationsDialog(mol, parent=self)
3671
+ dlg.modeSelected.connect(self._show_vibration)
3672
+ dlg.stateSelected.connect(self._show_excited_state)
3673
+ dlg.viewSpectrum.connect(self._on_view_spectrum)
3674
+ dlg.animationToggled.connect(self._on_anim_toggle)
3675
+ dlg.vectorsToggled.connect(self._on_vectors_toggle)
3676
+ dlg.finished.connect(self._on_calculations_closed)
3677
+ dlg.show()
3678
+
3679
+ def _calculate_rotational_constants(self):
3680
+ mol = self._canvas.molecule
3681
+ if not mol:
3682
+ QMessageBox.information(self, 'No molecule', "Load or build a molecule first.")
3683
+ return
3684
+
3685
+ rot_consts = calculate_rotational_constants(mol)
3686
+
3687
+ QMessageBox.information(self, 'Rotational constants', f"Rotational constants:\nA: {rot_consts[0]:10.4f} MHz\nB: {rot_consts[1]:10.4f} MHz\nC: {rot_consts[2]:10.4f} MHz")
3688
+ return(rot_consts)
3689
+
3690
+ def _calculate_dipole_moment(self):
3691
+ mol = self._canvas.molecule
3692
+ if not mol:
3693
+ QMessageBox.information(self, 'No molecule', "Load or build a molecule first.")
3694
+ return
3695
+
3696
+ if not HAS_OPENBABEL:
3697
+ QMessageBox.warning(
3698
+ self, "OpenBabel Not Found",
3699
+ "Dipole moment estimation requires OpenBabel.\n\n"
3700
+ "Install it with: pip install openbabel-wheel\n\n"
3701
+ "If already installed, set the BABEL_DATADIR environment "
3702
+ "variable to the folder containing UFF.prm."
3703
+ )
3704
+ return
3705
+
3706
+ mu_vec, mu_mag, atom_charges = calculate_dipole_moment(mol)
3707
+ if mu_mag is None:
3708
+ QMessageBox.information(self, 'Dipole moment',
3709
+ "Could not compute dipole moment.\n"
3710
+ "Ensure the molecule has at least 2 atoms and valid coordinates.")
3711
+ return
3712
+
3713
+ formula = chemical_formula(mol)
3714
+
3715
+ lines = [
3716
+ f"Molecule: {formula}",
3717
+ f"Charge: {mol.charge}",
3718
+ "",
3719
+ f"|μ| = {mu_mag:.4f} Debye",
3720
+ "",
3721
+ f"μx = {mu_vec[0]:+.4f} D",
3722
+ f"μy = {mu_vec[1]:+.4f} D",
3723
+ f"μz = {mu_vec[2]:+.4f} D",
3724
+ "",
3725
+ "Partial charges (EEM):",
3726
+ ]
3727
+ for elem, q in atom_charges:
3728
+ lines.append(f" {elem:2s} {q:+.4f} e")
3729
+
3730
+ QMessageBox.information(self, 'Dipole moment', "\n".join(lines))
3731
+
3732
+
3733
+ def _on_anim_toggle(self, enabled: bool):
3734
+ if enabled:
3735
+ self._anim_timer.start()
3736
+ else:
3737
+ self._anim_timer.stop()
3738
+ self._canvas.animation_amplitude = 0.0
3739
+ self._canvas.request_render()
3740
+
3741
+ def _on_vectors_toggle(self, enabled: bool):
3742
+ self._canvas._show_vectors = enabled
3743
+ self._canvas.request_render()
3744
+
3745
+ def _on_anim_step(self):
3746
+ self._anim_phase += 0.25
3747
+ if self._anim_phase > 2*math.pi:
3748
+ self._anim_phase -= 2*math.pi
3749
+ self._canvas.animation_phase = self._anim_phase
3750
+ self._canvas.animation_amplitude = 0.6
3751
+ self._canvas.request_render()
3752
+
3753
+ def _on_calculations_closed(self):
3754
+ self._anim_timer.stop()
3755
+ self._anim_phase = 0.0
3756
+ self._canvas.animation_phase = 0.0
3757
+ self._canvas.animation_amplitude = 0.0
3758
+ self._canvas.active_vectors = None
3759
+ self._canvas.arrows = None
3760
+ self._canvas.request_render()
3761
+ self._status.clearMessage()
3762
+
3763
+ def _on_view_spectrum(self, kind):
3764
+ if kind == "ir": self._view_ir_spectrum()
3765
+ else: self._view_uvvis_spectrum()
3766
+
3767
+ def _view_ir_spectrum(self):
3768
+ mol = self._canvas.molecule
3769
+ if not mol: return
3770
+ x = [m.frequency for m in mol.vibrational_modes]
3771
+ y = [m.intensity for m in mol.vibrational_modes]
3772
+ meta = f"Molecule: {mol.name}\nCalculation: Vibrational Frequencies (IR)"
3773
+ safe_name = get_safe_filename(mol.name)
3774
+ default_file = f"{safe_name}_ir_spectrum.txt"
3775
+ dlg = SpectrumDialog(x, y, "Frequency / cm-1", "Intensity (arb. units)", "IR Spectrum", meta, self)
3776
+ dlg.set_default_filename(default_file)
3777
+ dlg.exec()
3778
+
3779
+ def _view_uvvis_spectrum(self):
3780
+ mol = self._canvas.molecule
3781
+ if not mol: return
3782
+ x = [s.wavelength_nm for s in mol.excited_states]
3783
+ y = [s.oscillator_strength for s in mol.excited_states]
3784
+ meta = f"Molecule: {mol.name}\nCalculation: Excited States (TDDFT)"
3785
+ safe_name = get_safe_filename(mol.name)
3786
+ default_file = f"{safe_name}_uvvis_spectrum.txt"
3787
+ dlg = SpectrumDialog(x, y, "Wavelength / nm", "Oscillator Strength / f", "UV-Vis Spectrum", meta, self)
3788
+ dlg.set_default_filename(default_file)
3789
+ dlg.exec()
3790
+
3791
+ def _reset_state(self):
3792
+ self._canvas.arrows = None
3793
+ self._canvas.active_vectors = None
3794
+ self._canvas.request_render()
3795
+ if self._canvas.molecule:
3796
+ self._update_info_panel(self._canvas.molecule)
3797
+
3798
+ def _show_vibration(self, mode: VibrationalMode):
3799
+ # Scale displacements for visibility
3800
+ self._canvas.active_vectors = mode.displacements
3801
+ scale = 1.2
3802
+ arrows = []
3803
+ for i, d in enumerate(mode.displacements):
3804
+ arrows.append((i, d[0]*scale, d[1]*scale, d[2]*scale, "#00ff00"))
3805
+
3806
+ self._canvas.arrows = arrows
3807
+ self._canvas.request_render()
3808
+ self._status.showMessage(f"Visualizing mode {mode.index}: {mode.frequency:.1f} cm⁻¹")
3809
+
3810
+ def _show_excited_state(self, state: ExcitedState):
3811
+ self._canvas.arrows = None
3812
+ self._canvas.active_vectors = None
3813
+ self._canvas.request_render()
3814
+
3815
+ msg = f"Excited State {state.index}: {state.symmetry} | {state.energy_ev:.3f} eV | f={state.oscillator_strength:.4f}"
3816
+ self._status.showMessage(msg)
3817
+
3818
+ formula = chemical_formula(self._canvas.molecule)
3819
+ # Keep name consistent, don't add (S1) here if user wants consistency
3820
+ # Or just use plain text
3821
+ self._lbl_name.setText(formula)
3822
+
3823
+ def _on_legend_color_changed(self, elem: str, hex_color: str):
3824
+ self._color_overrides[elem] = hex_color
3825
+ self._canvas.color_overrides[elem] = hex_color
3826
+ self._canvas.request_render()
3827
+ if self._canvas.molecule:
3828
+ self._legend.update_for(self._canvas.molecule, self._color_overrides)
3829
+
3830
+ def _on_atom_color_changed(self, atom_idx: int, hex_color: str):
3831
+ mol = self._canvas.molecule
3832
+ if mol is None or atom_idx < 0 or atom_idx >= len(mol.atoms):
3833
+ return
3834
+ mol.atoms[atom_idx].color = hex_color
3835
+ self._canvas.request_render()
3836
+ self._legend.update_for(mol, self._color_overrides)
3837
+ self._status.showMessage(f"Atom {atom_idx + 1} colour set to {hex_color}.")
3838
+
3839
+ def _toggle_theme(self):
3840
+ new_theme = "light" if self._current_theme == "dark" else "dark"
3841
+ self._apply_theme(new_theme)
3842
+ self._canvas.background = THEMES[new_theme]["CANVAS"]
3843
+ self._canvas.request_render()
3844
+
3845
+ def _apply_theme(self, theme_name: str):
3846
+ self._current_theme = theme_name
3847
+ self.setStyleSheet(get_stylesheet(theme_name))
3848
+
3849
+ if hasattr(self, "_act_theme"):
3850
+ self._act_theme.setText("Switch to Dark Mode" if theme_name == "light" else "Switch to Light Mode")
3851
+
3852
+ # Update toolbar icon colors
3853
+ self._update_tool_icons()
3854
+
3855
+ self.setProperty("theme", theme_name)
3856
+ self.style().unpolish(self)
3857
+ self.style().polish(self)
3858
+
3859
+ def _update_tool_icons(self):
3860
+ if not hasattr(self, '_act_select_btn') or not hasattr(self, '_act_build_btn'):
3861
+ return
3862
+ assets_dir = os.path.join(os.path.dirname(__file__), "assets", "icons")
3863
+ color = '#ccd6f6' if self._current_theme == 'dark' else '#000000'
3864
+ self._act_select_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_select.svg"), color))
3865
+ self._act_build_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_draw.svg"), color))
3866
+ if hasattr(self, '_act_align_btn'):
3867
+ self._act_align_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_align.svg"), color))
3868
+
3869
+ def _update_info_panel(self, mol):
3870
+ """Populate sidebar labels and status bar for a loaded molecule."""
3871
+ formula = chemical_formula(mol)
3872
+ mass = molecular_mass(mol)
3873
+ charge = mol.charge
3874
+ src = self._current_source
3875
+ path = self._current_path
3876
+
3877
+ # Build name: plain text C60+ etc.
3878
+ if charge == 0:
3879
+ display_name = formula
3880
+ elif charge > 0:
3881
+ display_name = f"{formula}{charge}+" if charge > 1 else f"{formula}+"
3882
+ else:
3883
+ display_name = f"{formula}{abs(charge)}-" if charge < -1 else f"{formula}-"
3884
+
3885
+ charge_str = "neutral" if charge == 0 else (f"+{charge}" if charge > 0 else str(charge))
3886
+
3887
+ self._lbl_name.setText(display_name)
3888
+ self._lbl_charge.setText(f"Charge: {charge_str}")
3889
+ self._lbl_atoms.setText(f"Atoms: {len(mol.atoms)}")
3890
+ self._lbl_bonds.setText(f"Bonds: {len(mol.bonds)}")
3891
+ self._lbl_mass.setText(f"Mass: {mass:.3f} uma")
3892
+ self._lbl_src.setText(f"Source: {src}")
3893
+
3894
+ # Update Calculations Group
3895
+ has_vib = bool(mol.vibrational_modes)
3896
+ has_td = bool(mol.excited_states)
3897
+ has_energy = mol.g16_scf_energy is not None or mol.g16_opt_energy is not None
3898
+ has_any = has_vib or has_td or has_energy
3899
+ if has_any:
3900
+ self._calc_group.show()
3901
+ if has_energy:
3902
+ eng = mol.g16_opt_energy if mol.g16_opt_energy is not None else mol.g16_scf_energy
3903
+ self._lbl_energy.setText(f"Energy: {eng:.4f} Ha")
3904
+ self._lbl_energy.setVisible(True)
3905
+ else:
3906
+ self._lbl_energy.setVisible(False)
3907
+ self._lbl_vib.setVisible(has_vib)
3908
+ self._lbl_td.setVisible(has_td)
3909
+ self._lbl_vib.setText(f"Vibrations: {len(mol.vibrational_modes)} modes")
3910
+ self._lbl_td.setText(f"TD-DFT: {len(mol.excited_states)} states")
3911
+ else:
3912
+ self._calc_group.hide()
3913
+
3914
+ # Update main window title
3915
+ self.setWindowTitle(f"Molvector — {display_name}")
3916
+
3917
+ status_path = f"{path} | " if path else ""
3918
+ self._status.showMessage(
3919
+ f"{status_path}{display_name} | {len(mol.atoms)} atoms, "
3920
+ f"{len(mol.bonds)} bonds | {mass:.3f} uma [{src}]"
3921
+ )
3922
+ self._legend.update_for(mol, self._color_overrides)
3923
+
3924
+ def _show_molecule_info(self):
3925
+ mol = self._canvas.molecule
3926
+ if mol is None:
3927
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
3928
+ return
3929
+
3930
+ from collections import Counter
3931
+ formula = chemical_formula(mol)
3932
+ mass = molecular_mass(mol)
3933
+ charge = mol.charge
3934
+ charge_str = "neutral" if charge == 0 else (f"+{charge}" if charge > 0 else str(charge))
3935
+ counts = Counter(a.element for a in mol.atoms)
3936
+ elem_parts = [f"{e}\u2009{counts[e]}" for e in sorted(counts.keys())]
3937
+ elem_str = " " + ", ".join(elem_parts) if mol.atoms else "—"
3938
+ src = self._current_source or "—"
3939
+ path = self._current_path or "—"
3940
+
3941
+ dlg = QDialog(self)
3942
+ dlg.setWindowTitle(f"Info \u2014 {formula}")
3943
+ dlg.setMinimumWidth(400)
3944
+ layout = QVBoxLayout(dlg)
3945
+ layout.setSpacing(6)
3946
+
3947
+ for label, value in [
3948
+ ("Formula:", formula),
3949
+ ("Charge:", charge_str),
3950
+ ("Mass:", f"{mass:.3f} uma"),
3951
+ ("Atoms:", str(len(mol.atoms))),
3952
+ ("Bonds:", str(len(mol.bonds))),
3953
+ ("Source:", src),
3954
+ ("Path:", path),
3955
+ ("Elements:", elem_str),
3956
+ ]:
3957
+ row = QHBoxLayout()
3958
+ lbl = QLabel(label)
3959
+ lbl.setStyleSheet("font-weight: bold;")
3960
+ val = QLabel(value)
3961
+ val.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
3962
+ val.setWordWrap(True)
3963
+ row.addWidget(lbl)
3964
+ row.addWidget(val, 1)
3965
+ layout.addLayout(row)
3966
+
3967
+ layout.addSpacing(10)
3968
+ btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
3969
+ btn.rejected.connect(dlg.reject)
3970
+ layout.addWidget(btn)
3971
+ dlg.exec()
3972
+
3973
+ def _show_inchi(self):
3974
+ mol = self._canvas.molecule
3975
+ if mol is None:
3976
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
3977
+ return
3978
+
3979
+ inchi = generate_inchi(mol)
3980
+ if inchi is None:
3981
+ QMessageBox.warning(
3982
+ self, "InChI Error",
3983
+ "Failed to generate InChI for this molecule.\n"
3984
+ "Ensure RDKit is installed: pip install rdkit"
3985
+ )
3986
+ return
3987
+
3988
+ formula = chemical_formula(mol)
3989
+ warnings = check_valence_issues(mol)
3990
+
3991
+ dlg = QDialog(self)
3992
+ dlg.setWindowTitle(f"InChI \u2014 {formula}")
3993
+ dlg.setMinimumWidth(480)
3994
+ layout = QVBoxLayout(dlg)
3995
+ layout.setSpacing(6)
3996
+
3997
+ lbl = QLabel("InChI:")
3998
+ lbl.setStyleSheet("font-weight: bold;")
3999
+ layout.addWidget(lbl)
4000
+
4001
+ le = QLineEdit(inchi)
4002
+ le.setReadOnly(True)
4003
+ layout.addWidget(le)
4004
+
4005
+ copy_btn = QPushButton("Copy to Clipboard")
4006
+ copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(inchi))
4007
+ layout.addWidget(copy_btn)
4008
+
4009
+ nist_btn = QPushButton("Search in NIST WebBook")
4010
+ from urllib.parse import quote
4011
+ encoded = quote(inchi, safe='')
4012
+ nist_url = QUrl(f"https://webbook.nist.gov/cgi/cbook.cgi?InChI={encoded}&Units=SI")
4013
+ nist_btn.clicked.connect(lambda: QDesktopServices.openUrl(nist_url))
4014
+ layout.addWidget(nist_btn)
4015
+
4016
+ if warnings:
4017
+ layout.addSpacing(6)
4018
+ warn_lbl = QLabel("Valence warnings:")
4019
+ warn_lbl.setStyleSheet("font-weight: bold; color: #cc4444;")
4020
+ layout.addWidget(warn_lbl)
4021
+
4022
+ warn_text = QLabel("\n".join(warnings))
4023
+ warn_text.setStyleSheet("color: #cc4444;")
4024
+ warn_text.setWordWrap(True)
4025
+ layout.addWidget(warn_text)
4026
+
4027
+ layout.addSpacing(6)
4028
+ close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
4029
+ close_btn.rejected.connect(dlg.reject)
4030
+ layout.addWidget(close_btn)
4031
+ dlg.exec()
4032
+
4033
+ def _edit_charge(self):
4034
+ mol = self._canvas.molecule
4035
+ if mol is None:
4036
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4037
+ return
4038
+
4039
+ dlg = QDialog(self)
4040
+ dlg.setWindowTitle("Edit Charge")
4041
+ dlg.setMinimumWidth(240)
4042
+ layout = QVBoxLayout(dlg)
4043
+
4044
+ lbl = QLabel("Molecular charge:")
4045
+ layout.addWidget(lbl)
4046
+
4047
+ spin = QSpinBox()
4048
+ spin.setRange(-10, 10)
4049
+ spin.setValue(mol.charge)
4050
+ spin.setMinimumWidth(90)
4051
+ layout.addWidget(spin)
4052
+
4053
+ layout.addSpacing(6)
4054
+ btns = QDialogButtonBox(
4055
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
4056
+ )
4057
+ btns.accepted.connect(dlg.accept)
4058
+ btns.rejected.connect(dlg.reject)
4059
+ layout.addWidget(btns)
4060
+
4061
+ if dlg.exec() == QDialog.DialogCode.Accepted:
4062
+ mol.charge = spin.value()
4063
+ self._update_info_panel(mol)
4064
+ self._canvas.update()
4065
+ self._status.showMessage(f"Charge set to {mol.charge}")
4066
+
4067
+ def _open_file(self):
4068
+ path, _ = QFileDialog.getOpenFileName(
4069
+ self, "Open Molecule File", "",
4070
+ "All supported (*.xyz *.gjf *.com *.log *.out *.pdb *.mol *.txt);;"
4071
+ "XYZ (*.xyz);;Gaussian input (*.gjf *.com);;"
4072
+ "Gaussian log (*.log *.out);;PDB (*.pdb);;MDL Molfile (*.mol);;Text (*.txt);;All files (*)"
4073
+ )
4074
+ if path:
4075
+ self._load_and_display(path)
4076
+
4077
+ def _save_svg(self):
4078
+ if self._canvas.molecule is None:
4079
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4080
+ return
4081
+ safe_name = get_safe_filename(self._canvas.molecule.name)
4082
+ path, _ = QFileDialog.getSaveFileName(
4083
+ self, "Save SVG", f"{safe_name}_view.svg", "SVG files (*.svg)"
4084
+ )
4085
+ if not path:
4086
+ return
4087
+ try:
4088
+ data = self._canvas.get_svg_bytes(export_mode=True)
4089
+ with open(path, "wb") as f:
4090
+ f.write(data)
4091
+ self._status.showMessage(f"Saved: {path}")
4092
+ except Exception as e:
4093
+ QMessageBox.critical(self, "Error saving", str(e))
4094
+
4095
+ def _quick_svg_export(self):
4096
+ if self._canvas.molecule is None:
4097
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4098
+ return
4099
+ dlg = QuickExportDialog(self._canvas, self)
4100
+ dlg.show()
4101
+
4102
+ def _edit_shortcuts(self):
4103
+ _, saved_alt = self._load_shortcut_overrides()
4104
+ alt = dict(self.ALT_SHORTCUT_DEFAULTS)
4105
+ alt.update(saved_alt)
4106
+ alt = {k: v for k, v in alt.items() if k in self._shortcut_actions}
4107
+ dlg = ShortcutDialog(self._shortcut_actions, alt_defaults=alt, parent=self)
4108
+ if dlg.exec() == QDialog.DialogCode.Accepted:
4109
+ primary, alt = dlg.get_shortcuts()
4110
+ self._apply_shortcuts(primary)
4111
+ self._apply_alt_shortcuts(alt)
4112
+ self._save_shortcut_overrides(primary, alt)
4113
+
4114
+ def _apply_shortcut_config(self):
4115
+ primary, alt = self._load_shortcut_overrides()
4116
+ if primary:
4117
+ self._apply_shortcuts(primary)
4118
+ if alt:
4119
+ self._apply_alt_shortcuts(alt)
4120
+
4121
+ def _apply_shortcuts(self, overrides: dict):
4122
+ for aid, seq_str in overrides.items():
4123
+ if aid in self._shortcut_actions and seq_str:
4124
+ self._shortcut_actions[aid].setShortcut(seq_str)
4125
+
4126
+ def _apply_alt_shortcuts(self, alt: dict):
4127
+ for aid, seq_str in alt.items():
4128
+ if aid in self._shortcut_actions and seq_str:
4129
+ action = self._shortcut_actions[aid]
4130
+ primary = action.shortcut().toString()
4131
+ if primary:
4132
+ action.setShortcuts([QKeySequence(primary), QKeySequence(seq_str)])
4133
+ else:
4134
+ action.setShortcut(seq_str)
4135
+
4136
+ def _load_shortcut_overrides(self) -> tuple:
4137
+ cfg_path = os.path.join(os.path.dirname(__file__), "molvector_config.json")
4138
+ try:
4139
+ if os.path.exists(cfg_path):
4140
+ with open(cfg_path, "r", encoding="utf-8") as f:
4141
+ cfg = json.load(f)
4142
+ return cfg.get("shortcuts", {}), cfg.get("alt_shortcuts", {})
4143
+ except Exception:
4144
+ pass
4145
+ return {}, {}
4146
+
4147
+ def _save_shortcut_overrides(self, primary: dict, alt: dict):
4148
+ cfg_path = os.path.join(os.path.dirname(__file__), "molvector_config.json")
4149
+ config = {}
4150
+ try:
4151
+ if os.path.exists(cfg_path):
4152
+ with open(cfg_path, "r", encoding="utf-8") as f:
4153
+ config = json.load(f)
4154
+ except Exception:
4155
+ pass
4156
+ config["shortcuts"] = primary
4157
+ config["alt_shortcuts"] = alt
4158
+ try:
4159
+ with open(cfg_path, "w", encoding="utf-8") as f:
4160
+ json.dump(config, f, indent=2)
4161
+ except Exception:
4162
+ pass
4163
+
4164
+ def _export_view(self):
4165
+ if self._canvas.molecule is None:
4166
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4167
+ return
4168
+
4169
+ safe_name = get_safe_filename(self._canvas.molecule.name)
4170
+ filters = "PDF files (*.pdf);;PNG files (*.png);;JPEG files (*.jpg *.jpeg);;SVG files (*.svg)"
4171
+ path, sel_filter = QFileDialog.getSaveFileName(
4172
+ self, "Export View", f"{safe_name}_view.pdf", filters
4173
+ )
4174
+ if not path:
4175
+ return
4176
+
4177
+ try:
4178
+ # 1. Get SVG data from canvas
4179
+ svg_data = self._canvas.get_svg_bytes(export_mode=True)
4180
+ renderer = QSvgRenderer(QByteArray(svg_data))
4181
+
4182
+ # 2. Determine size (use high res for raster)
4183
+ # We'll use a 2x or 3x scale for images to make them crisp
4184
+ view_size = renderer.defaultSize()
4185
+ if view_size.isEmpty():
4186
+ view_size = QSize(1200, 900)
4187
+
4188
+ ext = os.path.splitext(path)[1].lower()
4189
+
4190
+ if ext == ".pdf":
4191
+ pdf = QPdfWriter(path)
4192
+ pdf.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
4193
+ # Center the molecule on the page
4194
+ painter = QPainter(pdf)
4195
+ try:
4196
+ # Scale molecule to fit page comfortably
4197
+ target_rect = painter.viewport()
4198
+ # Maintain aspect ratio
4199
+ svg_ratio = view_size.width() / view_size.height()
4200
+ page_ratio = target_rect.width() / target_rect.height()
4201
+
4202
+ if svg_ratio > page_ratio:
4203
+ w = target_rect.width()
4204
+ h = int(w / svg_ratio)
4205
+ else:
4206
+ h = target_rect.height()
4207
+ w = int(h * svg_ratio)
4208
+
4209
+ # Center it
4210
+ x = (target_rect.width() - w) // 2
4211
+ y = (target_rect.height() - h) // 2
4212
+ renderer.render(painter, QRectF(float(x), float(y), float(w), float(h)))
4213
+ finally:
4214
+ painter.end()
4215
+
4216
+ elif ext in (".png", ".jpg", ".jpeg"):
4217
+ # Scale up for high quality
4218
+ scale_factor = 2.0
4219
+ img_size = view_size * scale_factor
4220
+ img = QImage(img_size, QImage.Format.Format_ARGB32)
4221
+ img.fill(Qt.GlobalColor.transparent if ext == ".png" else Qt.GlobalColor.white)
4222
+
4223
+ painter = QPainter(img)
4224
+ try:
4225
+ renderer.render(painter)
4226
+ finally:
4227
+ painter.end()
4228
+
4229
+ img.save(path)
4230
+
4231
+ elif ext == ".svg":
4232
+ with open(path, "wb") as f:
4233
+ f.write(svg_data)
4234
+
4235
+ self._status.showMessage(f"Exported: {path}")
4236
+ except Exception as e:
4237
+ QMessageBox.critical(self, "Export Error", str(e))
4238
+
4239
+ def _save_as(self):
4240
+ mol = self._canvas.molecule
4241
+ if not mol:
4242
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4243
+ return
4244
+
4245
+ safe_name = get_safe_filename(mol.name)
4246
+ filters = "XYZ (*.xyz);;PDB (*.pdb);;MDL Molfile (*.mol);;Gaussian input (*.gjf *.com);;All files (*)"
4247
+ path, sel_filter = QFileDialog.getSaveFileName(
4248
+ self, "Save Molecule As", f"{safe_name}.xyz", filters
4249
+ )
4250
+ if not path:
4251
+ return
4252
+
4253
+ try:
4254
+ ext = os.path.splitext(path)[1].lower()
4255
+ if ext == ".xyz":
4256
+ text = save_xyz(mol)
4257
+ elif ext == ".pdb":
4258
+ text = save_pdb(mol)
4259
+ elif ext == ".mol":
4260
+ text = save_mol(mol)
4261
+ elif ext in (".gjf", ".com"):
4262
+ text = save_gaussian_input(mol)
4263
+ else:
4264
+ # Default to XYZ if extension is unknown
4265
+ text = save_xyz(mol)
4266
+
4267
+ with open(path, "w", encoding="utf-8") as f:
4268
+ f.write(text)
4269
+ self._status.showMessage(f"Saved: {path}")
4270
+ except Exception as e:
4271
+ QMessageBox.critical(self, "Save Error", str(e))
4272
+
4273
+ # ── Tool mode actions ────────────────────────────────────────────────────
4274
+
4275
+ def _update_status_for_mode(self):
4276
+ if self._canvas.build_mode:
4277
+ self._status.showMessage("Build: Left-click add atom · Drag to bond · Click bond change order · Scroll zoom")
4278
+ self._hint.setText("Left-click add atom\nDrag bond atoms\nClick bond change order")
4279
+ elif self._canvas.selection_mode:
4280
+ self._status.showMessage("Selection: Click select atom · Drag rectangle-select · Scroll zoom")
4281
+ self._hint.setText("Click select atom\nDrag rectangle-select")
4282
+ elif self._canvas.align_mode:
4283
+ self._status.showMessage("Align: Click bond align vertically · Shift+click align horizontally · Scroll zoom")
4284
+ self._hint.setText("Click bond align vertically\nShift+click align horizontally")
4285
+ else:
4286
+ self._status.showMessage("Left-drag rotate molecule · Right-drag pan · Scroll zoom")
4287
+ self._hint.setText("Drag rotate\nRight-drag pan\nScroll zoom")
4288
+
4289
+ def _toggle_selection_mode(self, enabled: bool):
4290
+ self._act_select_btn.setChecked(enabled)
4291
+ self._act_select_toggle.setChecked(enabled)
4292
+ self._canvas.selection_mode = enabled
4293
+ if enabled:
4294
+ self._act_build_toggle.setChecked(False)
4295
+ self._act_build_btn.setChecked(False)
4296
+ self._canvas.build_mode = False
4297
+ self._act_align_toggle.setChecked(False)
4298
+ self._act_align_btn.setChecked(False)
4299
+ self._canvas.align_mode = False
4300
+ self._set_build_options_visible(False)
4301
+ else:
4302
+ self._canvas.selected_atoms.clear()
4303
+ self._canvas.request_render()
4304
+ self._update_status_for_mode()
4305
+ self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
4306
+
4307
+ def _toggle_build_mode(self, enabled: bool):
4308
+ if enabled:
4309
+ self._act_select_btn.setChecked(False)
4310
+ self._act_select_toggle.setChecked(False)
4311
+ self._canvas.selection_mode = False
4312
+ self._act_align_toggle.setChecked(False)
4313
+ self._act_align_btn.setChecked(False)
4314
+ self._canvas.align_mode = False
4315
+ self._act_build_toggle.setChecked(enabled)
4316
+ self._act_build_btn.setChecked(enabled)
4317
+ self._canvas.build_mode = enabled
4318
+ self._set_build_options_visible(enabled)
4319
+ self._update_status_for_mode()
4320
+ self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
4321
+
4322
+ def _toggle_align_mode(self, enabled: bool):
4323
+ if enabled:
4324
+ self._act_select_btn.setChecked(False)
4325
+ self._act_select_toggle.setChecked(False)
4326
+ self._canvas.selection_mode = False
4327
+ self._act_build_btn.setChecked(False)
4328
+ self._act_build_toggle.setChecked(False)
4329
+ self._canvas.build_mode = False
4330
+ self._set_build_options_visible(False)
4331
+ self._act_align_toggle.setChecked(enabled)
4332
+ self._act_align_btn.setChecked(enabled)
4333
+ self._canvas.align_mode = enabled
4334
+ self._update_status_for_mode()
4335
+ self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
4336
+
4337
+ def _on_build_elem_change(self, elem: str):
4338
+ if elem == "...":
4339
+ prev = self._canvas.build_element
4340
+ dlg = PeriodicTableDialog(self, prev)
4341
+ dlg.elementSelected.connect(self._on_pick_from_periodic_table)
4342
+ if not dlg.exec():
4343
+ self._elem_combo.setCurrentText(prev)
4344
+ return
4345
+ self._canvas.build_element = elem
4346
+
4347
+ def _on_pick_from_periodic_table(self, sym: str):
4348
+ idx = self._elem_combo.findText(sym)
4349
+ if idx < 0:
4350
+ others_idx = self._elem_combo.findText("...")
4351
+ self._elem_combo.insertItem(others_idx, sym)
4352
+ self._elem_combo.setCurrentText(sym)
4353
+
4354
+ def _on_auto_h_toggle(self, checked: bool):
4355
+ self._canvas.auto_adjust_h = checked
4356
+
4357
+ def _toggle_bond_order(self, checked: bool):
4358
+ self._canvas.bond_order_disabled = checked
4359
+ if checked:
4360
+ if self._canvas.molecule:
4361
+ self._canvas._saved_bond_orders = [b.order for b in self._canvas.molecule.bonds]
4362
+ for b in self._canvas.molecule.bonds:
4363
+ b.order = 1
4364
+ self._act_bond_order.setText("Enable Bond Order")
4365
+ self._status.showMessage("Bond order disabled — all bonds set to single")
4366
+ else:
4367
+ if self._canvas.molecule and self._canvas._saved_bond_orders is not None:
4368
+ for i, b in enumerate(self._canvas.molecule.bonds):
4369
+ if i < len(self._canvas._saved_bond_orders):
4370
+ b.order = self._canvas._saved_bond_orders[i]
4371
+ self._canvas._saved_bond_orders = None
4372
+ self._act_bond_order.setText("Disable Bond Order")
4373
+ self._status.showMessage("Bond order enabled — click bonds to cycle order")
4374
+ self._canvas.request_render()
4375
+
4376
+ def _clear_molecule(self):
4377
+ if QMessageBox.question(self, "Clear", "Clear the entire molecule?") == QMessageBox.StandardButton.Yes:
4378
+ self._save_history()
4379
+ self._canvas.molecule = Molecule("New Molecule", atoms=[])
4380
+ self._canvas.request_render()
4381
+ self._update_info_panel(self._canvas.molecule)
4382
+
4383
+ def _clean_molecule(self):
4384
+ if not self._canvas.molecule or not self._canvas.molecule.atoms:
4385
+ return
4386
+
4387
+ if not HAS_OPENBABEL:
4388
+ QMessageBox.warning(
4389
+ self, "OpenBabel Not Found",
4390
+ "Geometry optimization requires OpenBabel.\n\n"
4391
+ "Install it with: pip install openbabel-wheel\n\n"
4392
+ "If already installed, set the BABEL_DATADIR environment "
4393
+ "variable to the folder containing UFF.prm."
4394
+ )
4395
+ return
4396
+
4397
+ self._save_history()
4398
+ steps_taken = optimize_geometry(
4399
+ self._canvas.molecule,
4400
+ max_steps=self._ff_max_steps,
4401
+ tol=self._ff_tol,
4402
+ )
4403
+ self._canvas.request_render()
4404
+ self._update_info_panel(self._canvas.molecule)
4405
+ self._status.showMessage(f"Geometry optimized ({steps_taken} iterations).", 3000)
4406
+
4407
+ def _on_structure_changed(self):
4408
+ # UI updates only; history should be saved BEFORE the change occurs
4409
+ # to capture the 'before' state correctly.
4410
+ self._update_info_panel(self._canvas.molecule)
4411
+
4412
+ def _save_history(self):
4413
+ import copy
4414
+ if self._canvas.molecule:
4415
+ # We save a snapshot of the CURRENT state before it gets modified.
4416
+ snap = copy.deepcopy(self._canvas.molecule)
4417
+ self._history.append(snap)
4418
+ if len(self._history) > self._max_history:
4419
+ self._history.pop(0)
4420
+ # Clear redo stack on NEW action
4421
+ self._redo_stack.clear()
4422
+
4423
+ def _undo(self):
4424
+ if not self._history:
4425
+ self._status.showMessage("Nothing to undo.")
4426
+ return
4427
+
4428
+ import copy
4429
+ # Save CURRENT state to redo stack before going back
4430
+ if self._canvas.molecule:
4431
+ self._redo_stack.append(copy.deepcopy(self._canvas.molecule))
4432
+
4433
+ # Restore the most recent snapshot
4434
+ prev = self._history.pop()
4435
+ self._canvas.molecule = prev
4436
+ self._canvas.request_render()
4437
+ self._update_info_panel(self._canvas.molecule)
4438
+ self._status.showMessage("Undo successful.")
4439
+
4440
+ def _redo(self):
4441
+ if not self._redo_stack:
4442
+ self._status.showMessage("Nothing to redo.")
4443
+ return
4444
+
4445
+ import copy
4446
+ # Save CURRENT state to history before going forward
4447
+ if self._canvas.molecule:
4448
+ self._history.append(copy.deepcopy(self._canvas.molecule))
4449
+ if len(self._history) > self._max_history:
4450
+ self._history.pop(0)
4451
+
4452
+ # Restore from redo stack
4453
+ next_state = self._redo_stack.pop()
4454
+ self._canvas.molecule = next_state
4455
+ self._canvas.request_render()
4456
+ self._update_info_panel(self._canvas.molecule)
4457
+ self._status.showMessage("Redo successful.")
4458
+
4459
+ def _edit_ff_settings(self):
4460
+ dlg = QDialog(self)
4461
+ dlg.setWindowTitle("Optimization Parameters")
4462
+ l = QVBoxLayout(dlg)
4463
+
4464
+ from PyQt6.QtWidgets import QFormLayout, QSpinBox, QDialogButtonBox
4465
+ form = QFormLayout()
4466
+
4467
+ s_steps = QSpinBox()
4468
+ s_steps.setRange(10, 100000)
4469
+ s_steps.setValue(self._ff_max_steps)
4470
+ form.addRow("Max Steps:", s_steps)
4471
+
4472
+ l.addLayout(form)
4473
+
4474
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
4475
+ btns.accepted.connect(dlg.accept)
4476
+ btns.rejected.connect(dlg.reject)
4477
+ l.addWidget(btns)
4478
+
4479
+ if dlg.exec() == QDialog.DialogCode.Accepted:
4480
+ self._ff_max_steps = s_steps.value()
4481
+ self._clean_molecule()
4482
+
4483
+ # ── View actions ─────────────────────────────────────────────────────────
4484
+
4485
+ def _set_bond_style(self, style: str):
4486
+ self._canvas.bond_style = style
4487
+ self._canvas.request_render()
4488
+
4489
+ def _set_ball_size(self, scale: float):
4490
+ self._canvas.atom_scale = scale
4491
+ self._canvas.request_render()
4492
+
4493
+ def _set_bond_width(self, w: float):
4494
+ self._canvas.bond_width_px = w
4495
+ self._canvas.request_render()
4496
+
4497
+ # ── Edit actions ──────────────────────────────────────────────────────────
4498
+
4499
+ def _load_appearance_config(self):
4500
+ cfg = SettingsDialog.load_config()
4501
+ if cfg is None:
4502
+ return
4503
+ g = cfg.get("general", {})
4504
+ o = cfg.get("overlay", {})
4505
+ a = cfg.get("atoms_bonds", {})
4506
+ if g:
4507
+ theme = g.get("theme", "light")
4508
+ if theme != self._current_theme:
4509
+ self._apply_theme(theme)
4510
+ self._canvas.background = o.get("bg_color", g.get("bg_color", self._canvas.background))
4511
+ self._restore_on_startup = g.get("restore_molecule", False)
4512
+ if a:
4513
+ self._canvas.atom_scale = a.get("atom_scale", self._canvas.atom_scale)
4514
+ self._canvas.bond_width_px = a.get("bond_width_px", self._canvas.bond_width_px)
4515
+ self._canvas.bond_style = a.get("bond_style", self._canvas.bond_style)
4516
+ self._canvas.bond_color = a.get("bond_color", self._canvas.bond_color)
4517
+ self._canvas.atom_border_mode = a.get("atom_border_mode", self._canvas.atom_border_mode)
4518
+ self._canvas.atom_border_scale = a.get("atom_border_scale", self._canvas.atom_border_scale)
4519
+ self._canvas.atom_border_width = a.get("atom_border_width", self._canvas.atom_border_width)
4520
+ self._canvas.lighting_intensity = a.get("lighting_intensity", self._canvas.lighting_intensity)
4521
+ self._canvas.light_position = a.get("light_position", self._canvas.light_position)
4522
+ self._canvas.roughness = a.get("roughness", self._canvas.roughness)
4523
+ self._color_overrides = a.get("color_overrides", {})
4524
+ self._canvas.color_overrides = self._color_overrides
4525
+ if o:
4526
+ self._canvas.show_axes = o.get("show_axes", self._canvas.show_axes)
4527
+ self._canvas.show_principal_axes = o.get("show_principal_axes", self._canvas.show_principal_axes)
4528
+ self._canvas.axes_position = o.get("axes_position", self._canvas.axes_position)
4529
+ self._canvas.principal_axes_position = o.get("principal_axes_position", self._canvas.principal_axes_position)
4530
+ self._canvas.request_render()
4531
+
4532
+ def _maybe_restore_molecule(self):
4533
+ if not getattr(self, '_restore_on_startup', False):
4534
+ return
4535
+ path = self.LAST_MOLECULE_FILE
4536
+ if not os.path.isfile(path):
4537
+ return
4538
+ try:
4539
+ with open(path) as f:
4540
+ content = f.read().strip()
4541
+ try:
4542
+ data = json.loads(content)
4543
+ mol_path = data.get("path", "")
4544
+ view = data
4545
+ except (json.JSONDecodeError, ValueError):
4546
+ mol_path = content
4547
+ view = None
4548
+ if mol_path and os.path.isfile(mol_path):
4549
+ self._load_and_display(mol_path)
4550
+ if view and self._canvas.molecule:
4551
+ c = self._canvas
4552
+ if "rot" in view:
4553
+ c._rot = np.array(view["rot"])
4554
+ if "zoom" in view:
4555
+ c._zoom = view["zoom"]
4556
+ if "pan" in view:
4557
+ c._pan = np.array(view["pan"])
4558
+ if "axes_ref" in view:
4559
+ c._axes_ref = np.array(view["axes_ref"])
4560
+ if "default_view" in view:
4561
+ c._default_view = np.array(view["default_view"])
4562
+ c.request_render()
4563
+ except Exception:
4564
+ pass
4565
+
4566
+ def _edit_settings(self, tab_index=0):
4567
+ cfg = SettingsDialog.load_config() or {}
4568
+ restore = cfg.get("general", {}).get("restore_molecule", False)
4569
+ orig = (self._current_theme, self._canvas.background,
4570
+ self._canvas.atom_scale, self._canvas.bond_width_px,
4571
+ self._canvas.bond_style, dict(self._color_overrides),
4572
+ self._canvas.atom_border_mode, self._canvas.atom_border_scale,
4573
+ self._canvas.atom_border_width, self._canvas.bond_color,
4574
+ self._canvas.lighting_intensity, self._canvas.light_position,
4575
+ self._canvas.roughness,
4576
+ self._canvas.show_axes, self._canvas.show_principal_axes,
4577
+ self._canvas.axes_position, self._canvas.principal_axes_position,
4578
+ restore)
4579
+
4580
+ def _live_update(theme, bg, ball, bw, style, colors, border_mode, border_scale,
4581
+ border_width, bcol, lighting, pos, rough,
4582
+ show_xyz, show_abc, xyz_pos, abc_pos):
4583
+ if theme != self._current_theme:
4584
+ self._apply_theme(theme)
4585
+ self._canvas.background = bg
4586
+ self._canvas.atom_scale = ball
4587
+ self._canvas.bond_width_px = bw
4588
+ self._canvas.bond_style = style
4589
+ self._canvas.bond_color = bcol
4590
+ self._canvas.atom_border_mode = border_mode
4591
+ self._canvas.atom_border_scale = border_scale
4592
+ self._canvas.atom_border_width = border_width
4593
+ self._canvas.lighting_intensity = lighting
4594
+ self._canvas.light_position = pos
4595
+ self._canvas.roughness = rough
4596
+ self._color_overrides = colors
4597
+ self._canvas.color_overrides = colors
4598
+ self._canvas.show_axes = show_xyz
4599
+ self._canvas.show_principal_axes = show_abc
4600
+ self._canvas.axes_position = xyz_pos
4601
+ self._canvas.principal_axes_position = abc_pos
4602
+ if self._canvas.molecule:
4603
+ self._legend.update_for(self._canvas.molecule, colors)
4604
+ self._canvas.request_render()
4605
+
4606
+ dlg = SettingsDialog(
4607
+ orig[0], orig[1], orig[2], orig[3], orig[4], orig[5],
4608
+ atom_border_mode=orig[6], atom_border_scale=orig[7], atom_border_width=orig[8],
4609
+ bond_color=orig[9], lighting_intensity=orig[10],
4610
+ light_position=orig[11], roughness=orig[12],
4611
+ show_axes=orig[13], show_principal_axes=orig[14],
4612
+ axes_position=orig[15], principal_axes_position=orig[16],
4613
+ restore_molecule=orig[17],
4614
+ live_callback=_live_update, parent=self,
4615
+ )
4616
+ dlg.tabs.setCurrentIndex(tab_index)
4617
+ if dlg.exec() == QDialog.DialogCode.Accepted:
4618
+ self._apply_theme(dlg.theme)
4619
+ self._canvas.background = dlg.bg_color
4620
+ self._canvas.atom_scale = dlg.ball_scale
4621
+ self._canvas.bond_width_px = dlg.bond_width
4622
+ self._canvas.bond_style = dlg.bond_style
4623
+ self._canvas.bond_color = dlg.bond_color
4624
+ self._canvas.atom_border_mode = dlg.border_mode
4625
+ self._canvas.atom_border_scale = dlg.border_scale
4626
+ self._canvas.atom_border_width = dlg.border_width
4627
+ self._canvas.lighting_intensity = dlg.lighting_intensity
4628
+ self._canvas.light_position = dlg.light_position
4629
+ self._canvas.roughness = dlg.roughness
4630
+ self._canvas.show_axes = dlg.show_axes
4631
+ self._canvas.show_principal_axes = dlg.show_principal_axes
4632
+ self._canvas.axes_position = dlg.axes_position
4633
+ self._canvas.principal_axes_position = dlg.principal_axes_position
4634
+ self._color_overrides = dlg._color_overrides
4635
+ self._canvas.color_overrides = dlg._color_overrides
4636
+ if self._canvas.molecule:
4637
+ self._legend.update_for(self._canvas.molecule, dlg._color_overrides)
4638
+ self._canvas.request_render()
4639
+ else:
4640
+ (self._current_theme, self._canvas.background,
4641
+ self._canvas.atom_scale, self._canvas.bond_width_px,
4642
+ self._canvas.bond_style, self._color_overrides,
4643
+ self._canvas.atom_border_mode, self._canvas.atom_border_scale,
4644
+ self._canvas.atom_border_width, self._canvas.bond_color,
4645
+ self._canvas.lighting_intensity,
4646
+ self._canvas.light_position,
4647
+ self._canvas.roughness,
4648
+ self._canvas.show_axes,
4649
+ self._canvas.show_principal_axes,
4650
+ self._canvas.axes_position,
4651
+ self._canvas.principal_axes_position,
4652
+ _) = orig
4653
+ self._canvas.color_overrides = self._color_overrides
4654
+ if self._canvas.molecule:
4655
+ self._legend.update_for(self._canvas.molecule, self._color_overrides)
4656
+ self._canvas.request_render()
4657
+
4658
+ def _edit_style(self):
4659
+ self._edit_settings(tab_index=2)
4660
+
4661
+ def _edit_overlay(self):
4662
+ self._edit_settings(tab_index=1)
4663
+
4664
+ def closeEvent(self, event):
4665
+ if self._current_path and self._canvas.molecule:
4666
+ try:
4667
+ c = self._canvas
4668
+ data = {
4669
+ "path": self._current_path,
4670
+ "rot": c._rot.tolist(),
4671
+ "zoom": c._zoom,
4672
+ "pan": c._pan.tolist(),
4673
+ "axes_ref": c._axes_ref.tolist(),
4674
+ "default_view": c._default_view.tolist(),
4675
+ }
4676
+ with open(self.LAST_MOLECULE_FILE, "w") as f:
4677
+ json.dump(data, f)
4678
+ except Exception:
4679
+ pass
4680
+ super().closeEvent(event)
4681
+
4682
+ def _edit_atom_colors(self):
4683
+ mol = self._canvas.molecule
4684
+ if mol is None:
4685
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4686
+ return
4687
+ elements = sorted({a.element for a in mol.atoms})
4688
+ orig_overrides = dict(self._color_overrides)
4689
+
4690
+ def _live_update(overrides):
4691
+ self._color_overrides = overrides
4692
+ self._canvas.color_overrides = overrides
4693
+ self._legend.update_for(mol, overrides)
4694
+ self._canvas.request_render()
4695
+
4696
+ dlg = AtomColorDialog(elements, orig_overrides, live_callback=_live_update, parent=self)
4697
+ if dlg.exec() == QDialog.DialogCode.Accepted:
4698
+ self._color_overrides = dlg.get_overrides()
4699
+ self._canvas.color_overrides = self._color_overrides
4700
+ self._legend.update_for(mol, self._color_overrides)
4701
+ self._canvas.request_render()
4702
+ else:
4703
+ self._color_overrides = orig_overrides
4704
+ self._canvas.color_overrides = orig_overrides
4705
+ self._legend.update_for(mol, orig_overrides)
4706
+ self._canvas.request_render()
4707
+
4708
+ def _reset_colors(self):
4709
+ self._color_overrides = {}
4710
+ self._canvas.color_overrides = {}
4711
+ if self._canvas.molecule:
4712
+ for atom in self._canvas.molecule.atoms:
4713
+ atom.color = None
4714
+ self._legend.update_for(self._canvas.molecule, {})
4715
+ self._canvas.request_render()
4716
+ self._status.showMessage("Atom colours reset to CPK defaults.")
4717
+
4718
+ def _paint_selected_atoms(self):
4719
+ mol = self._canvas.molecule
4720
+ if mol is None:
4721
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4722
+ return
4723
+ sel = self._canvas.selected_atoms
4724
+ if not sel:
4725
+ QMessageBox.information(self, "No selection", "Select atoms first, then use Paint.")
4726
+ return
4727
+ col = QColorDialog.getColor(QColor("#ff6666"), self, "Pick Colour for Selected Atoms")
4728
+ if not col.isValid():
4729
+ return
4730
+ hex_col = col.name()
4731
+ for idx in sel:
4732
+ mol.atoms[idx].color = hex_col
4733
+ self._canvas.selected_atoms.clear()
4734
+ self._canvas.color_overrides = {}
4735
+ self._color_overrides = {}
4736
+ self._legend.update_for(mol, {})
4737
+ self._canvas.request_render()
4738
+ self._status.showMessage(f"Painted {len(sel)} atom(s) with {hex_col}.")
4739
+
4740
+ def _clear_paint(self):
4741
+ mol = self._canvas.molecule
4742
+ if mol is None:
4743
+ QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
4744
+ return
4745
+ n = sum(1 for a in mol.atoms if a.color is not None)
4746
+ if n == 0:
4747
+ self._status.showMessage("No painted atoms to clear.")
4748
+ return
4749
+ for atom in mol.atoms:
4750
+ atom.color = None
4751
+ self._legend.update_for(mol, self._color_overrides)
4752
+ self._canvas.request_render()
4753
+ self._status.showMessage(f"Cleared paint on {n} atom(s).")
4754
+
4755
+ # ── View actions ──────────────────────────────────────────────────────────
4756
+
4757
+ def _pick_background(self):
4758
+ col = QColorDialog.getColor(
4759
+ QColor(self._canvas.background), self, "Pick Background Colour"
4760
+ )
4761
+ if col.isValid():
4762
+ self._canvas.background = col.name()
4763
+ self._canvas.request_render()
4764
+
4765
+ # ── Help ──────────────────────────────────────────────────────────────────
4766
+
4767
+ def _open_test_molecule(self):
4768
+ test_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "test_files", "c5h6.xyz")
4769
+ if os.path.isfile(test_path):
4770
+ self._load_and_display(test_path)
4771
+
4772
+ def _show_about(self):
4773
+ QMessageBox.about(self, "About Molvector",
4774
+ "<b>Molvector</b> — 3D Molecule Viewer<br><br>"
4775
+ "Ball-and-stick rendering, G16 input generator.<br>"
4776
+ "Parsers: XYZ · Gaussian input (.gjf/.com) · Gaussian log (.log/.out) · Mol file<br><br>"
4777
+ "Dependencies: PyQt6 · NumPy · svgwrite · molstrudel · RDKit · OpenBabel"
4778
+ )
4779
+
4780
+ # ── callbacks ─────────────────────────────────────────────────────────────
4781
+
4782
+ def eventFilter(self, obj, event):
4783
+ if event.type() == QEvent.Type.MouseButtonDblClick and obj is self._zoom_slider:
4784
+ self._zoom_slider.setValue(100)
4785
+ return True
4786
+ return super().eventFilter(obj, event)
4787
+
4788
+ def _on_zoom_change(self, value):
4789
+ self._canvas._zoom = value / 100.0
4790
+ self._zoom_lbl.setText(f"{value}%")
4791
+ self._canvas.request_render(delay_ms=60)
4792
+
4793
+ def _reset_view(self):
4794
+ self._canvas.reset_view()
4795
+ self._zoom_slider.setValue(100)
4796
+ self._zoom_lbl.setText("100%")
4797
+
4798
+ def _on_rotation_changed(self):
4799
+ pct = int(self._canvas._zoom * 100)
4800
+ self._zoom_lbl.setText(f"{pct}%")
4801
+ self._zoom_slider.blockSignals(True)
4802
+ self._zoom_slider.setValue(pct)
4803
+ self._zoom_slider.blockSignals(False)
4804
+
4805
+ # ─────────────────────────────────────────────────────────────────────────────
4806
+ # ENTRY POINT
4807
+ # ─────────────────────────────────────────────────────────────────────────────
4808
+
4809
+ def main():
4810
+ app = QApplication(sys.argv)
4811
+ app.setApplicationName("Molvector")
4812
+ app.setFont(QFont("sans-serif", 10))
4813
+
4814
+ if platform.system() == "Windows":
4815
+ try:
4816
+ import ctypes
4817
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Molvector")
4818
+ except Exception:
4819
+ pass
4820
+
4821
+
4822
+ win = MainWindow()
4823
+ set_icons(app, win)
4824
+ win.show()
4825
+
4826
+ # Optional: open file from command line
4827
+ if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
4828
+ win._load_and_display(sys.argv[1])
4829
+
4830
+ sys.exit(app.exec())
4831
+
4832
+
4833
+ if __name__ == "__main__":
4834
+ main()