MoleditPy-linux 3.5.2__py3-none-any.whl → 3.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moleditpy_linux/main.py CHANGED
@@ -148,4 +148,14 @@ def main() -> None:
148
148
  app = QApplication([sys.argv[0]] + remaining)
149
149
  window = MainWindow(initial_file=args.file, safe_mode=args.safe)
150
150
  window.show()
151
+
152
+ # Force Windows to refresh taskbar/titlebar icon after event loop starts
153
+ if sys.platform == "win32":
154
+ try:
155
+ from PyQt6.QtCore import QTimer
156
+
157
+ QTimer.singleShot(100, lambda: window.setWindowIcon(window.windowIcon()))
158
+ except Exception:
159
+ pass
160
+
151
161
  sys.exit(app.exec())
@@ -22,10 +22,8 @@ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera #
22
22
 
23
23
 
24
24
  try:
25
- from .move_group_dialog import MoveGroupDialog
26
25
  from .atom_picking import pick_atom_index_from_screen
27
26
  except ImportError:
28
- from moleditpy_linux.ui.move_group_dialog import MoveGroupDialog
29
27
  from moleditpy_linux.ui.atom_picking import pick_atom_index_from_screen
30
28
 
31
29
  from rdkit import Geometry
@@ -107,10 +105,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
107
105
  self._mouse_press_pos = None
108
106
 
109
107
  # Check Move Group dialog
108
+ # Check Move Group or Move Selected Atoms dialog
110
109
  move_group_dialog = None
111
110
  for widget in QApplication.topLevelWidgets():
112
111
  try:
113
- if isinstance(widget, MoveGroupDialog) and widget.isVisible():
112
+ if (
113
+ type(widget).__name__
114
+ in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
115
+ and widget.isVisible()
116
+ ):
114
117
  move_group_dialog = widget
115
118
  break
116
119
  except (AttributeError, RuntimeError, TypeError):
@@ -147,6 +150,20 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
147
150
  self._suppress_next_left_button_up = True
148
151
  return # Disable camera rotation
149
152
  else:
153
+ if type(move_group_dialog).__name__ == "MoveSelectedAtomsDialog":
154
+ # For MoveSelectedAtomsDialog, we toggle ONLY the clicked atom, no BFS!
155
+ def _deferred_toggle(
156
+ idx=clicked_atom_idx, dlg=move_group_dialog
157
+ ):
158
+ try:
159
+ dlg.on_atom_picked(idx)
160
+ except (AttributeError, RuntimeError):
161
+ pass
162
+
163
+ QTimer.singleShot(0, _deferred_toggle)
164
+ self._suppress_next_left_button_up = True
165
+ return
166
+
150
167
  # Clicked outside group - Search connected component
151
168
  visited = set()
152
169
  queue = [clicked_atom_idx]
@@ -172,8 +189,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
172
189
 
173
190
  # Multi-selection with Ctrl
174
191
  is_ctrl_pressed = bool(
175
- QApplication.keyboardModifiers()
176
- & Qt.KeyboardModifier.ControlModifier
192
+ (
193
+ QApplication.keyboardModifiers()
194
+ & Qt.KeyboardModifier.ControlModifier
195
+ )
196
+ or (
197
+ self.GetInteractor()
198
+ and self.GetInteractor().GetControlKey()
199
+ )
177
200
  )
178
201
 
179
202
  if is_ctrl_pressed:
@@ -208,8 +231,10 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
208
231
  super(CustomInteractorStyle, self).OnLeftButtonDown()
209
232
  return
210
233
 
234
+ interactor = self.GetInteractor()
211
235
  is_temp_mode = bool(
212
- QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
236
+ (QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
237
+ or (interactor and interactor.GetAltKey())
213
238
  )
214
239
  is_edit_active = mw.edit_3d_manager.is_3d_edit_mode or is_temp_mode
215
240
 
@@ -293,11 +318,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
293
318
  """
294
319
  mw = self.main_window
295
320
 
296
- # Check if Move Group dialog is open
321
+ # Check if Move Group dialog or Move Selected Atoms dialog is open
297
322
  move_group_dialog = None
298
323
  try:
299
324
  for widget in QApplication.topLevelWidgets():
300
- if isinstance(widget, MoveGroupDialog) and widget.isVisible():
325
+ if (
326
+ type(widget).__name__
327
+ in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
328
+ and widget.isVisible()
329
+ ):
301
330
  move_group_dialog = widget
302
331
  break
303
332
  except (AttributeError, RuntimeError, TypeError) as e:
@@ -350,11 +379,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
350
379
  """
351
380
  mw = self.main_window
352
381
 
353
- # Move Group drag handling
382
+ # Move Group / Selected Atoms drag handling
354
383
  move_group_dialog = None
355
384
  try:
356
385
  for widget in QApplication.topLevelWidgets():
357
- if isinstance(widget, MoveGroupDialog) and widget.isVisible():
386
+ if (
387
+ type(widget).__name__
388
+ in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
389
+ and widget.isVisible()
390
+ ):
358
391
  move_group_dialog = widget
359
392
  break
360
393
  except (AttributeError, RuntimeError, TypeError):
@@ -442,11 +475,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
442
475
  """
443
476
  mw = self.main_window
444
477
 
445
- # Finalize Move Group drag
478
+ # Finalize Move Group / Selected Atoms drag
446
479
  move_group_dialog = None
447
480
  try:
448
481
  for widget in QApplication.topLevelWidgets():
449
- if isinstance(widget, MoveGroupDialog) and widget.isVisible():
482
+ if (
483
+ type(widget).__name__
484
+ in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
485
+ and widget.isVisible()
486
+ ):
450
487
  move_group_dialog = widget
451
488
  break
452
489
  except (AttributeError, RuntimeError, TypeError):
@@ -749,11 +786,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
749
786
  """
750
787
  mw = self.main_window
751
788
 
752
- # Finalize Move Group rotation
789
+ # Finalize Move Group / Selected Atoms rotation
753
790
  move_group_dialog = None
754
791
  try:
755
792
  for widget in QApplication.topLevelWidgets():
756
- if isinstance(widget, MoveGroupDialog) and widget.isVisible():
793
+ if (
794
+ type(widget).__name__
795
+ in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
796
+ and widget.isVisible()
797
+ ):
757
798
  move_group_dialog = widget
758
799
  break
759
800
  except (AttributeError, RuntimeError, TypeError):
@@ -18,7 +18,8 @@ import json
18
18
  import os
19
19
  from typing import Any, List, Literal, Optional, cast
20
20
 
21
- from PyQt6.QtWidgets import QInputDialog, QMessageBox
21
+ from PyQt6.QtWidgets import QInputDialog, QMessageBox, QDialog
22
+ from PyQt6.QtCore import Qt
22
23
 
23
24
 
24
25
  try:
@@ -33,6 +34,7 @@ try:
33
34
  from .dihedral_dialog import DihedralDialog
34
35
  from .mirror_dialog import MirrorDialog
35
36
  from .move_group_dialog import MoveGroupDialog
37
+ from .move_selected_atoms_dialog import MoveSelectedAtomsDialog
36
38
  from .periodic_table_dialog import PeriodicTableDialog
37
39
  from .planarize_dialog import PlanarizeDialog
38
40
  from .settings_dialog import SettingsDialog
@@ -53,6 +55,7 @@ except ImportError:
53
55
  from moleditpy_linux.ui.dihedral_dialog import DihedralDialog
54
56
  from moleditpy_linux.ui.mirror_dialog import MirrorDialog
55
57
  from moleditpy_linux.ui.move_group_dialog import MoveGroupDialog
58
+ from moleditpy_linux.ui.move_selected_atoms_dialog import MoveSelectedAtomsDialog
56
59
  from moleditpy_linux.ui.periodic_table_dialog import PeriodicTableDialog
57
60
  from moleditpy_linux.ui.planarize_dialog import PlanarizeDialog
58
61
  from moleditpy_linux.ui.settings_dialog import SettingsDialog
@@ -213,6 +216,13 @@ class DialogManager:
213
216
  self.host, "Error", f"Failed to save template: {str(e)}"
214
217
  )
215
218
 
219
+ def _show_modeless_dialog(self, dialog: QDialog) -> None:
220
+ """Show a modeless dialog on top, especially important for macOS."""
221
+ dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
222
+ dialog.show()
223
+ dialog.raise_()
224
+ dialog.activateWindow()
225
+
216
226
  def open_translation_dialog(self) -> None:
217
227
  """Open the translation dialog"""
218
228
  # Get preselected atoms
@@ -229,8 +239,8 @@ class DialogManager:
229
239
  preselected_atoms,
230
240
  parent=self.host,
231
241
  )
232
- self.host.edit_3d_manager.active_3d_dialogs.append(dialog) # Keep reference
233
- dialog.show() # Use show for modeless display
242
+ self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
243
+ self._show_modeless_dialog(dialog)
234
244
  dialog.accepted.connect(
235
245
  lambda: self.host.statusBar().showMessage("Translation applied.")
236
246
  )
@@ -256,7 +266,7 @@ class DialogManager:
256
266
  parent=self.host,
257
267
  )
258
268
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
259
- dialog.show()
269
+ self._show_modeless_dialog(dialog)
260
270
  dialog.accepted.connect(
261
271
  lambda: self.host.statusBar().showMessage("Group transformation applied.")
262
272
  )
@@ -265,6 +275,34 @@ class DialogManager:
265
275
  lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
266
276
  )
267
277
 
278
+ def open_move_selected_atoms_dialog(self) -> None:
279
+ """Open Move Selected Atoms dialog"""
280
+ # Get preselected atoms
281
+ preselected_atoms = self._get_preselected_atoms_3d()
282
+
283
+ # Disable measurement mode
284
+ if self.host.edit_3d_manager.measurement_mode:
285
+ self.host.init_manager.measurement_action.setChecked(False)
286
+ self.host.edit_3d_manager.toggle_measurement_mode(False)
287
+
288
+ dialog = MoveSelectedAtomsDialog(
289
+ self.host.view_3d_manager.current_mol,
290
+ self.host,
291
+ preselected_atoms,
292
+ parent=self.host,
293
+ )
294
+ self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
295
+ self._show_modeless_dialog(dialog)
296
+ dialog.accepted.connect(
297
+ lambda: self.host.statusBar().showMessage(
298
+ "Selected atoms transformation applied."
299
+ )
300
+ )
301
+ dialog.accepted.connect(self.host.edit_actions_manager.push_undo_state)
302
+ dialog.finished.connect(
303
+ lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
304
+ )
305
+
268
306
  def open_align_plane_dialog(self, plane: str) -> None:
269
307
  """Open align dialog"""
270
308
  # Get pre-selected atoms
@@ -283,7 +321,7 @@ class DialogManager:
283
321
  parent=self.host,
284
322
  )
285
323
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
286
- dialog.show()
324
+ self._show_modeless_dialog(dialog)
287
325
  dialog.accepted.connect(
288
326
  lambda: self.host.statusBar().showMessage(
289
327
  f"Atoms aligned to {plane.upper()} plane."
@@ -311,7 +349,7 @@ class DialogManager:
311
349
  parent=self.host,
312
350
  )
313
351
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
314
- dialog.show()
352
+ self._show_modeless_dialog(dialog)
315
353
  dialog.accepted.connect(
316
354
  lambda: self.host.statusBar().showMessage(
317
355
  "Selection planarized to best-fit plane."
@@ -340,7 +378,7 @@ class DialogManager:
340
378
  parent=self.host,
341
379
  )
342
380
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
343
- dialog.show()
381
+ self._show_modeless_dialog(dialog)
344
382
  dialog.accepted.connect(
345
383
  lambda: self.host.statusBar().showMessage(
346
384
  f"Atoms aligned to {axis.upper()}-axis."
@@ -368,7 +406,7 @@ class DialogManager:
368
406
  parent=self.host,
369
407
  )
370
408
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
371
- dialog.show()
409
+ self._show_modeless_dialog(dialog)
372
410
  dialog.accepted.connect(
373
411
  lambda: self.host.statusBar().showMessage("Bond length adjusted.")
374
412
  )
@@ -394,7 +432,7 @@ class DialogManager:
394
432
  parent=self.host,
395
433
  )
396
434
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
397
- dialog.show()
435
+ self._show_modeless_dialog(dialog)
398
436
  dialog.accepted.connect(
399
437
  lambda: self.host.statusBar().showMessage("Angle adjusted.")
400
438
  )
@@ -420,7 +458,7 @@ class DialogManager:
420
458
  parent=self.host,
421
459
  )
422
460
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
423
- dialog.show()
461
+ self._show_modeless_dialog(dialog)
424
462
  dialog.accepted.connect(
425
463
  lambda: self.host.statusBar().showMessage("Dihedral angle adjusted.")
426
464
  )
@@ -440,8 +478,18 @@ class DialogManager:
440
478
  self.host.init_manager.measurement_action.setChecked(False)
441
479
  self.host.edit_3d_manager.toggle_measurement_mode(False)
442
480
 
443
- dialog = MirrorDialog(self.host.view_3d_manager.current_mol, self.host)
444
- dialog.exec()
481
+ dialog = MirrorDialog(
482
+ self.host.view_3d_manager.current_mol, self.host, parent=self.host
483
+ )
484
+ self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
485
+ self._show_modeless_dialog(dialog)
486
+ dialog.accepted.connect(
487
+ lambda: self.host.statusBar().showMessage("Mirror applied.")
488
+ )
489
+ dialog.accepted.connect(self.host.edit_actions_manager.push_undo_state)
490
+ dialog.finished.connect(
491
+ lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
492
+ )
445
493
 
446
494
  def open_settings_dialog(self) -> None:
447
495
  """Open the application settings dialog."""
@@ -468,7 +516,7 @@ class DialogManager:
468
516
  self.host.view_3d_manager.current_mol, self.host, parent=self.host
469
517
  )
470
518
  self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
471
- dialog.show()
519
+ self._show_modeless_dialog(dialog)
472
520
  dialog.finished.connect(
473
521
  lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
474
522
  )
@@ -1866,6 +1866,14 @@ class MainInitManager:
1866
1866
  edit_3d_menu.addAction(translation_action)
1867
1867
  self.host.translation_action = translation_action
1868
1868
 
1869
+ move_selected_atoms_action = QAction("Move Selected Atoms...", self.host)
1870
+ move_selected_atoms_action.triggered.connect(
1871
+ self.host.dialog_manager.open_move_selected_atoms_dialog
1872
+ )
1873
+ move_selected_atoms_action.setEnabled(False)
1874
+ edit_3d_menu.addAction(move_selected_atoms_action)
1875
+ self.host.move_selected_atoms_action = move_selected_atoms_action
1876
+
1869
1877
  move_group_action = QAction("Move Group...", self.host)
1870
1878
  move_group_action.triggered.connect(
1871
1879
  self.host.dialog_manager.open_move_group_dialog
@@ -228,6 +228,9 @@ class TemplateMixin:
228
228
  return math.hypot(ax - bx, ay - by)
229
229
 
230
230
  # --- 1) Map already clicked existing_items to template vertices ---
231
+ alt_pressed = bool(
232
+ QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
233
+ )
231
234
  existing_items = existing_items or []
232
235
  used_indices = set()
233
236
  ref_lengths = [
@@ -258,8 +261,14 @@ class TemplateMixin:
258
261
 
259
262
  # --- 2) Enumerate existing atoms in the scene from self.data.atoms and map them ---
260
263
  mapped_atoms = {it for it in atom_items if it is not None}
261
- if self.get_setting("template_fusing_enabled_2d", True):
262
- map_threshold = self.get_setting("template_fusing_distance_2d", 14.0)
264
+ if (
265
+ hasattr(self, "data")
266
+ and hasattr(self.data, "atoms")
267
+ and self.data.atoms is not None
268
+ and self.get_setting("template_fusing_enabled_2d", True)
269
+ and not alt_pressed
270
+ ):
271
+ map_threshold = self.get_setting("template_fusing_distance_2d", 7.0)
263
272
  for i, p in enumerate(points):
264
273
  if atom_items[i] is not None:
265
274
  continue
@@ -353,12 +362,14 @@ class TemplateMixin:
353
362
 
354
363
  return atom_items
355
364
 
356
- def update_template_preview(self, pos: QPointF) -> None:
365
+ def update_template_preview(
366
+ self, pos: QPointF, alt_pressed: Optional[bool] = None
367
+ ) -> None:
357
368
  mode_parts = self.mode.split("_")
358
369
 
359
370
  # Check if this is a user template
360
371
  if len(mode_parts) >= 3 and mode_parts[1] == "user":
361
- self.update_user_template_preview(pos)
372
+ self.update_user_template_preview(pos, alt_pressed=alt_pressed)
362
373
  return
363
374
 
364
375
  is_aromatic = False
@@ -371,6 +382,10 @@ class TemplateMixin:
371
382
  except ValueError:
372
383
  return
373
384
 
385
+ if alt_pressed is None:
386
+ alt_pressed = bool(
387
+ QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
388
+ )
374
389
  item = None
375
390
  if pos:
376
391
  snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
@@ -383,7 +398,7 @@ class TemplateMixin:
383
398
  break
384
399
 
385
400
  points, bonds_info = [], []
386
- l = DEFAULT_BOND_LENGTH
401
+ bond_len = DEFAULT_BOND_LENGTH
387
402
  self.template_context = {}
388
403
 
389
404
  if isinstance(item, AtomItem):
@@ -391,7 +406,10 @@ class TemplateMixin:
391
406
  continuous_angle = math.atan2(pos.y() - p0.y(), pos.x() - p0.x())
392
407
  snap_angle_rad = math.radians(15)
393
408
  snapped_angle = round(continuous_angle / snap_angle_rad) * snap_angle_rad
394
- p1 = p0 + QPointF(l * math.cos(snapped_angle), l * math.sin(snapped_angle))
409
+ p1 = p0 + QPointF(
410
+ bond_len * math.cos(snapped_angle),
411
+ bond_len * math.sin(snapped_angle),
412
+ )
395
413
  points = self._calculate_polygon_from_edge(p0, p1, n)
396
414
  self.template_context["items"] = [item]
397
415
 
@@ -409,8 +427,8 @@ class TemplateMixin:
409
427
  points = [
410
428
  pos
411
429
  + QPointF(
412
- l * math.cos(start_angle + i * angle_step),
413
- l * math.sin(start_angle + i * angle_step),
430
+ bond_len * math.cos(start_angle + i * angle_step),
431
+ bond_len * math.sin(start_angle + i * angle_step),
414
432
  )
415
433
  for i in range(n)
416
434
  ]
@@ -426,6 +444,57 @@ class TemplateMixin:
426
444
  self.template_context["points"] = points
427
445
  self.template_context["bonds_info"] = bonds_info
428
446
 
447
+ # Snap individual preview vertices to nearby atoms to reflect template fusing visually
448
+ if self.get_setting("template_fusing_enabled_2d", True) and not alt_pressed:
449
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
450
+ mapped_atoms = set(self.template_context.get("items", []))
451
+ used_indices = set()
452
+ click_map_threshold = max(0.5 * bond_len, 8.0)
453
+
454
+ # Map already clicked existing items first
455
+ for ex_item in self.template_context.get("items", []):
456
+ try:
457
+ ex_pos = ex_item.pos()
458
+ best_idx, best_d = -1, float("inf")
459
+ for i, p in enumerate(points):
460
+ if i in used_indices:
461
+ continue
462
+ d = math.hypot(p.x() - ex_pos.x(), p.y() - ex_pos.y())
463
+ if d < best_d:
464
+ best_d, best_idx = d, i
465
+ if best_idx != -1 and best_d <= click_map_threshold:
466
+ points[best_idx] = ex_pos
467
+ used_indices.add(best_idx)
468
+ except (AttributeError, TypeError, IndexError):
469
+ pass
470
+
471
+ # Map unmapped points to other unmapped nearby atoms in the scene
472
+ if (
473
+ hasattr(self, "data")
474
+ and hasattr(self.data, "atoms")
475
+ and self.data.atoms is not None
476
+ ):
477
+ for i, p in enumerate(points):
478
+ if i in used_indices:
479
+ continue
480
+ nearby = None
481
+ best_d = float("inf")
482
+ for atom_data in self.data.atoms.values():
483
+ a_item = atom_data.get("item")
484
+ if not a_item or a_item in mapped_atoms:
485
+ continue
486
+ try:
487
+ d = math.hypot(
488
+ p.x() - a_item.pos().x(), p.y() - a_item.pos().y()
489
+ )
490
+ except (AttributeError, TypeError):
491
+ continue
492
+ if d < best_d:
493
+ best_d, nearby = d, a_item
494
+ if nearby and best_d <= fuse_dist:
495
+ points[i] = nearby.pos()
496
+ mapped_atoms.add(nearby)
497
+
429
498
  self.template_preview.set_geometry(points, is_aromatic)
430
499
 
431
500
  self.template_preview.show()
@@ -558,7 +627,9 @@ class TemplateMixin:
558
627
  if atom_id in self.data.atoms and self.data.atoms[atom_id]["item"]:
559
628
  self.data.atoms[atom_id]["item"].update_style()
560
629
 
561
- def update_user_template_preview(self, pos: QPointF) -> None:
630
+ def update_user_template_preview(
631
+ self, pos: QPointF, alt_pressed: Optional[bool] = None
632
+ ) -> None:
562
633
  """Update user template preview"""
563
634
  # Robust preview: avoid self.data.atoms for preview-only atoms
564
635
  if not hasattr(self, "user_template_data") or not self.user_template_data:
@@ -659,8 +730,8 @@ class KeyboardMixin:
659
730
  Returns the offset QPointF.
660
731
  """
661
732
  start_pos = start_atom.pos()
662
- l = bond_length
663
- new_pos_offset = QPointF(0, -l) # Default offset (up)
733
+ bond_len = bond_length
734
+ new_pos_offset = QPointF(0, -bond_len) # Default offset (up)
664
735
 
665
736
  # Get non-H neighbors
666
737
  neighbor_positions = []
@@ -673,7 +744,7 @@ class KeyboardMixin:
673
744
 
674
745
  if num_non_H_neighbors == 0:
675
746
  # Zero bonds: default direction (up)
676
- new_pos_offset = QPointF(0, -l)
747
+ new_pos_offset = QPointF(0, -bond_len)
677
748
 
678
749
  elif num_non_H_neighbors == 1:
679
750
  # One bond: ~120/60 degree angle
@@ -688,7 +759,7 @@ class KeyboardMixin:
688
759
  new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
689
760
  rotated_vector = QPointF(new_vx, new_vy)
690
761
  line = QLineF(QPointF(0, 0), rotated_vector)
691
- line.setLength(l)
762
+ line.setLength(bond_len)
692
763
  new_pos_offset = line.p2()
693
764
 
694
765
  elif num_non_H_neighbors == 3:
@@ -705,10 +776,10 @@ class KeyboardMixin:
705
776
  # SUM_TOLERANCE is now a module-level constant
706
777
  if bond_vectors_sum.manhattanLength() > SUM_TOLERANCE:
707
778
  new_direction_line = QLineF(QPointF(0, 0), -bond_vectors_sum)
708
- new_direction_line.setLength(l)
779
+ new_direction_line.setLength(bond_len)
709
780
  new_pos_offset = new_direction_line.p2()
710
781
  else:
711
- new_pos_offset = QPointF(l * 0.7071, -l * 0.7071)
782
+ new_pos_offset = QPointF(bond_len * 0.7071, -bond_len * 0.7071)
712
783
 
713
784
  else: # 2, 4+ bonds: skeleton continuation or over-bonding
714
785
  bond_vectors_sum = QPointF(0, 0)
@@ -721,26 +792,36 @@ class KeyboardMixin:
721
792
 
722
793
  if bond_vectors_sum.manhattanLength() > 0.01:
723
794
  new_direction_line = QLineF(QPointF(0, 0), -bond_vectors_sum)
724
- new_direction_line.setLength(l)
795
+ new_direction_line.setLength(bond_len)
725
796
  new_pos_offset = new_direction_line.p2()
726
797
  else:
727
798
  # Default (up) if sum is zero
728
- new_pos_offset = QPointF(0, -l)
799
+ new_pos_offset = QPointF(0, -bond_len)
729
800
 
730
801
  return new_pos_offset
731
802
 
732
803
  def keyPressEvent(self, event: Any) -> None:
804
+ if not self.views():
805
+ try:
806
+ from PyQt6.QtWidgets import QGraphicsScene
807
+
808
+ QGraphicsScene.keyPressEvent(self, event) # type: ignore[arg-type]
809
+ except (ImportError, AttributeError, TypeError, RuntimeError):
810
+ logging.exception("Error delegating keyPressEvent")
811
+ return
812
+
733
813
  view = self.views()[0]
734
814
  cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
735
815
  transform = view.transform()
736
816
  key = event.key()
737
817
  modifiers = event.modifiers()
818
+
738
819
  item_at_cursor = None
739
820
  if key == Qt.Key.Key_4:
740
821
  snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
741
822
  item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
742
823
  elif self.get_setting("template_fusing_enabled_2d", True):
743
- fuse_dist = self.get_setting("template_fusing_distance_2d", 14.0)
824
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
744
825
  item_at_cursor = self.find_atom_near(cursor_pos, tol=fuse_dist)
745
826
  if item_at_cursor is None:
746
827
  item_at_cursor = self.itemAt(cursor_pos, transform)
@@ -759,7 +840,7 @@ class KeyboardMixin:
759
840
  # Calculate placement like update_template_preview
760
841
  if isinstance(item_at_cursor, AtomItem):
761
842
  p0 = item_at_cursor.pos()
762
- l = DEFAULT_BOND_LENGTH
843
+ bond_len = DEFAULT_BOND_LENGTH
763
844
 
764
845
  # Check if this is a terminal atom (exactly 1 neighbor)
765
846
  neighbor_positions = []
@@ -807,7 +888,8 @@ class KeyboardMixin:
807
888
  angle_plus if diff_plus < diff_minus else angle_minus
808
889
  )
809
890
  p1 = p0 + QPointF(
810
- l * math.cos(best_angle), l * math.sin(best_angle)
891
+ bond_len * math.cos(best_angle),
892
+ bond_len * math.sin(best_angle),
811
893
  )
812
894
  bend_dir = p0 - v_to_neighbor
813
895
  points = self._calculate_polygon_from_edge(
@@ -816,9 +898,9 @@ class KeyboardMixin:
816
898
  else:
817
899
  direction = QLineF(p0, cursor_pos).unitVector()
818
900
  p1 = (
819
- p0 + direction.p2() * l
901
+ p0 + direction.p2() * bond_len
820
902
  if direction.length() > 0
821
- else p0 + QPointF(l, 0)
903
+ else p0 + QPointF(bond_len, 0)
822
904
  )
823
905
  points = self._calculate_polygon_from_edge(
824
906
  p0, p1, n, cursor_pos=cursor_pos
@@ -1076,8 +1158,10 @@ class KeyboardMixin:
1076
1158
 
1077
1159
  if start_atom:
1078
1160
  start_pos = start_atom.pos()
1079
- l = DEFAULT_BOND_LENGTH
1080
- new_pos_offset = self._calculate_new_atom_position(start_atom, l)
1161
+ bond_len = DEFAULT_BOND_LENGTH
1162
+ new_pos_offset = self._calculate_new_atom_position(
1163
+ start_atom, bond_len
1164
+ )
1081
1165
 
1082
1166
  # SNAP_DISTANCE is a module-level constant
1083
1167
  target_pos = start_pos + new_pos_offset
@@ -311,7 +311,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
311
311
  and self.get_setting("template_fusing_enabled_2d", True)
312
312
  and self.press_pos
313
313
  ):
314
- fuse_dist = self.get_setting("template_fusing_distance_2d", 14.0)
314
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
315
315
  item = self.find_atom_near(self.press_pos, tol=fuse_dist)
316
316
  if item is None:
317
317
  item = self.itemAt(self.press_pos, self.views()[0].transform())
@@ -361,7 +361,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
361
361
 
362
362
  target_atom = None
363
363
  if self.get_setting("template_fusing_enabled_2d", True) and current_pos:
364
- fuse_dist = self.get_setting("template_fusing_distance_2d", 14.0)
364
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
365
365
  target_atom = self.find_atom_near(current_pos, tol=fuse_dist)
366
366
  else:
367
367
  for item in self.items(current_pos):
@@ -558,7 +558,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
558
558
  line = QLineF(self.start_atom.pos(), end_pos)
559
559
  end_item = None
560
560
  if self.get_setting("template_fusing_enabled_2d", True) and end_pos:
561
- fuse_dist = self.get_setting("template_fusing_distance_2d", 14.0)
561
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
562
562
  end_item = self.find_atom_near(end_pos, tol=fuse_dist)
563
563
  if end_item is None:
564
564
  end_item = self.itemAt(end_pos, self.views()[0].transform())
@@ -612,7 +612,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
612
612
  else:
613
613
  end_item = None
614
614
  if self.get_setting("template_fusing_enabled_2d", True) and end_pos:
615
- fuse_dist = self.get_setting("template_fusing_distance_2d", 14.0)
615
+ fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
616
616
  end_item = self.find_atom_near(end_pos, tol=fuse_dist)
617
617
  if end_item is None:
618
618
  end_item = self.itemAt(end_pos, self.views()[0].transform())