MoleditPy-linux 4.6.1__tar.gz → 4.7.1__tar.gz

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.
Files changed (83) hide show
  1. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  5. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/plugins/plugin_interface.py +37 -0
  6. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/angle_dialog.py +4 -1
  7. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/atom_item.py +29 -41
  8. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/bond_item.py +17 -26
  9. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/bond_length_dialog.py +4 -1
  10. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/custom_interactor_style.py +8 -3
  11. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/dihedral_dialog.py +4 -1
  12. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/edit_3d_logic.py +1 -1
  13. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/edit_actions_logic.py +1 -1
  14. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/io_logic.py +36 -15
  15. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/main_window_init.py +1 -1
  16. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/molecular_scene_handler.py +5 -9
  17. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +6 -5
  18. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/zoomable_view.py +31 -1
  19. moleditpy_linux-4.7.1/src/moleditpy_linux/utils/hit_radius.py +32 -0
  20. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/LICENSE +0 -0
  21. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/README.md +0 -0
  22. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/setup.cfg +0 -0
  23. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  24. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  25. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  26. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  27. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/__init__.py +0 -0
  28. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/__main__.py +0 -0
  29. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  30. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/assets/icon.icns +0 -0
  31. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/assets/icon.ico +0 -0
  32. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/assets/icon.png +0 -0
  33. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/core/__init__.py +0 -0
  34. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  35. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/core/molecular_data.py +0 -0
  36. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/main.py +0 -0
  37. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/plugins/__init__.py +0 -0
  38. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  39. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  40. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/__init__.py +0 -0
  41. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  42. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  43. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  44. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  45. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/app_state.py +0 -0
  46. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  47. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  48. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  49. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  50. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  51. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  52. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  53. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  54. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  55. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  56. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/export_logic.py +0 -0
  57. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  58. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/main_window.py +0 -0
  59. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  60. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  61. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  62. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  63. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  64. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  65. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  66. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  67. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  68. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  69. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  70. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  71. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/string_importers.py +0 -0
  72. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  73. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  74. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  75. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  76. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  77. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/__init__.py +0 -0
  79. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/constants.py +0 -0
  80. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/default_settings.py +0 -0
  81. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  82. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  83. {moleditpy_linux-4.6.1 → moleditpy_linux-4.7.1}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.6.1
3
+ Version: 4.7.1
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.6.1"
8
+ version = "4.7.1"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.6.1
3
+ Version: 4.7.1
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -75,6 +75,7 @@ src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py
75
75
  src/moleditpy_linux/utils/__init__.py
76
76
  src/moleditpy_linux/utils/constants.py
77
77
  src/moleditpy_linux/utils/default_settings.py
78
+ src/moleditpy_linux/utils/hit_radius.py
78
79
  src/moleditpy_linux/utils/sip_isdeleted_safe.py
79
80
  src/moleditpy_linux/utils/suppress_log.py
80
81
  src/moleditpy_linux/utils/system_utils.py
@@ -409,6 +409,43 @@ class PluginContext:
409
409
  mw.init_manager.settings[namespaced] = value
410
410
  mw.init_manager.settings_dirty = True
411
411
 
412
+ def get_current_file(self) -> Optional[str]:
413
+ """
414
+ Path of the file the application currently has open, or None.
415
+
416
+ Returns:
417
+ The path, or None when nothing is open.
418
+ """
419
+ mw = self.get_main_window()
420
+ if mw and hasattr(mw, "init_manager"):
421
+ path = mw.init_manager.current_file_path
422
+ return str(path) if path else None
423
+ return None
424
+
425
+ def set_current_file(self, path: Optional[str]) -> None:
426
+ """
427
+ Record *path* as the file the application has open and retitle its window.
428
+
429
+ Use this after a plugin file opener loads a file, so the window names it
430
+ the way it names a project. Assigning ``init_manager.current_file_path``
431
+ directly leaves the title showing the previous file: it is only rebuilt
432
+ on demand.
433
+
434
+ Args:
435
+ path: File path to show, or None for "Untitled".
436
+ """
437
+ mw = self.get_main_window()
438
+ if mw is None or not hasattr(mw, "init_manager"):
439
+ return
440
+ try:
441
+ mw.init_manager.current_file_path = path
442
+ if hasattr(mw, "state_manager"):
443
+ fn = getattr(mw.state_manager, "update_window_title", None)
444
+ if fn:
445
+ fn()
446
+ except (AttributeError, RuntimeError):
447
+ logging.debug("set_current_file failed", exc_info=True)
448
+
412
449
  def mark_project_modified(self) -> None:
413
450
  """Mark the current project as having unsaved changes and update the window title."""
414
451
  mw = self.get_main_window()
@@ -487,7 +487,10 @@ class AngleDialog(GeometryBaseDialog):
487
487
  if abs(achieved - requested) < 0.5:
488
488
  return
489
489
  with suppress_log(AttributeError, RuntimeError, TypeError):
490
- self.main_window.statusBar().showMessage(
490
+ status_bar = self.main_window.statusBar()
491
+ if status_bar is None:
492
+ return
493
+ status_bar.showMessage(
491
494
  f"Angle unchanged ({achieved:.2f} deg): the three atoms rotate "
492
495
  "together, which happens for an angle inside a ring.",
493
496
  5000,
@@ -37,6 +37,7 @@ from ..utils.constants import (
37
37
  FONT_FAMILY,
38
38
  FONT_WEIGHT_BOLD,
39
39
  )
40
+ from ..utils.hit_radius import scene_hit_radius
40
41
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
41
42
 
42
43
  if TYPE_CHECKING:
@@ -111,8 +112,8 @@ class AtomItem(QGraphicsItem):
111
112
  font_italic = False
112
113
  font_underline = False
113
114
 
114
- scene = self.scene()
115
- if scene is not None and hasattr(scene, "get_setting"):
115
+ scene: Any = self.scene()
116
+ if scene is not None:
116
117
  font_size = scene.get_setting("atom_font_size_2d", 20)
117
118
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
118
119
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -133,15 +134,15 @@ class AtomItem(QGraphicsItem):
133
134
  )
134
135
  self.update()
135
136
 
136
- def boundingRect(self) -> QRectF:
137
- """Calculate the bounding rectangle for the atom item."""
137
+ def visual_rect(self) -> QRectF:
138
+ """Return the rectangle the atom draws into; highlights use this."""
138
139
  font_size = 20
139
140
  font_family = FONT_FAMILY
140
141
  font_bold = True
141
142
  font_italic = False
142
143
  font_underline = False
143
- scene = self.scene()
144
- if scene is not None and hasattr(scene, "get_setting"):
144
+ scene: Any = self.scene()
145
+ if scene is not None:
145
146
  font_size = scene.get_setting("atom_font_size_2d", 20)
146
147
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
147
148
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -251,6 +252,13 @@ class AtomItem(QGraphicsItem):
251
252
  # 3. Add final margins for selection highlights, etc.
252
253
  return full_visual_rect.adjusted(-3, -3, 3, 3)
253
254
 
255
+ def boundingRect(self) -> QRectF:
256
+ """Return the drawn rect plus the hit shape, which Qt requires it to cover."""
257
+ hit_r = self.hit_radius()
258
+ return self.visual_rect().united(
259
+ QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0)
260
+ )
261
+
254
262
  def get_bg_ellipse_path(self) -> QPainterPath:
255
263
  """Return the elliptical background path used for bond endpoint clipping."""
256
264
  path = QPainterPath()
@@ -262,8 +270,8 @@ class AtomItem(QGraphicsItem):
262
270
  font_bold = True
263
271
  font_italic = False
264
272
  font_underline = False
265
- scene = self.scene()
266
- if scene is not None and hasattr(scene, "get_setting"):
273
+ scene: Any = self.scene()
274
+ if scene is not None:
267
275
  font_size = scene.get_setting("atom_font_size_2d", 20)
268
276
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
269
277
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -330,33 +338,13 @@ class AtomItem(QGraphicsItem):
330
338
  path.addEllipse(bg_rect)
331
339
  return path
332
340
 
333
- def shape(self) -> QPainterPath:
334
- """Define the shape of the atom item for collision detection.
335
-
336
- The hit radius is derived from the ``bond_snapping_distance_2d``
337
- setting so that hover highlight matches the snapping / keyboard
338
- instant-switch zone exactly. The radius is expressed in screen
339
- pixels and divided by the current view scale.
340
- """
341
- scene = self.scene()
342
- if not scene or not scene.views():
343
- path = QPainterPath()
344
- hit_r = max(4.0, ATOM_RADIUS - 6.0) * 2
345
- path.addEllipse(QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0))
346
- return path
347
-
348
- view = scene.views()[0]
349
- scale = view.transform().m11()
350
- if scale <= 0.0:
351
- scale = 1.0
341
+ def hit_radius(self) -> float:
342
+ """Return the hit/snap radius in scene units for the current zoom."""
343
+ return scene_hit_radius(self.scene(), fallback=max(4.0, ATOM_RADIUS - 6.0) * 2)
352
344
 
353
- # Use the same setting that find_atom_near() uses for snapping,
354
- # so hover highlight and snap zones are identical.
355
- if hasattr(scene, "get_setting"):
356
- hit_px = scene.get_setting("bond_snapping_distance_2d", 14.0)
357
- else:
358
- hit_px = 14.0
359
- scene_radius = hit_px / scale
345
+ def shape(self) -> QPainterPath:
346
+ """Define the collision area: a fixed pixel radius, never the drawn label."""
347
+ scene_radius = self.hit_radius()
360
348
 
361
349
  path = QPainterPath()
362
350
  path.addEllipse(QPointF(0, 0), scene_radius, scene_radius)
@@ -374,11 +362,11 @@ class AtomItem(QGraphicsItem):
374
362
  # Color logic: check if we should use bond color (uniform) or CPK (element-specific)
375
363
  color = CPK_COLORS.get(self.symbol, CPK_COLORS["DEFAULT"])
376
364
  # Use bond color if specified in settings
377
- scene = self.scene()
378
- if hasattr(scene, "get_setting") and (
379
- self.symbol == "H" or scene.get_setting("atom_use_bond_color_2d", False) # type: ignore[union-attr]
365
+ scene: Any = self.scene()
366
+ if scene is not None and (
367
+ self.symbol == "H" or scene.get_setting("atom_use_bond_color_2d", False)
380
368
  ):
381
- custom_color = scene.get_setting("bond_color_2d", "#222222") # type: ignore[union-attr]
369
+ custom_color = scene.get_setting("bond_color_2d", "#222222")
382
370
  if isinstance(custom_color, str):
383
371
  color = QColor(custom_color)
384
372
 
@@ -533,17 +521,17 @@ class AtomItem(QGraphicsItem):
533
521
  if self.has_problem:
534
522
  painter.setBrush(Qt.BrushStyle.NoBrush)
535
523
  painter.setPen(QPen(QColor(255, 0, 0, 200), 4))
536
- painter.drawRect(self.boundingRect())
524
+ painter.drawRect(self.visual_rect())
537
525
  elif self.isSelected():
538
526
  painter.setBrush(Qt.BrushStyle.NoBrush)
539
527
  painter.setPen(QPen(QColor(0, 100, 255), 3))
540
- painter.drawRect(self.boundingRect())
528
+ painter.drawRect(self.visual_rect())
541
529
  if (not self.isSelected()) and getattr(self, "hovered", False):
542
530
  pen = QPen(QColor(144, 238, 144, 200), 3)
543
531
  pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
544
532
  painter.setBrush(Qt.BrushStyle.NoBrush)
545
533
  painter.setPen(pen)
546
- painter.drawRect(self.boundingRect())
534
+ painter.drawRect(self.visual_rect())
547
535
 
548
536
  def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
549
537
  """Propagate position and scene changes to connected bond items."""
@@ -13,7 +13,7 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
  import math
15
15
  import logging
16
- from typing import TYPE_CHECKING, Optional, Tuple, Union
16
+ from typing import TYPE_CHECKING, Any, Optional, Tuple, Union
17
17
 
18
18
  from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt
19
19
  from PyQt6.QtGui import (
@@ -43,6 +43,7 @@ from ..utils.constants import (
43
43
  FONT_WEIGHT_BOLD,
44
44
  HOVER_PEN_WIDTH,
45
45
  )
46
+ from ..utils.hit_radius import scene_hit_radius
46
47
 
47
48
  if TYPE_CHECKING:
48
49
  from .atom_item import AtomItem
@@ -231,6 +232,10 @@ class BondItem(QGraphicsItem):
231
232
  # return zero line to prevent downstream crashes.
232
233
  return QLineF(0, 0, 0, 0)
233
234
 
235
+ def hit_radius(self) -> float:
236
+ """Return the click/hover half-width in scene units for the current zoom."""
237
+ return scene_hit_radius(self.scene())
238
+
234
239
  def boundingRect(self) -> QRectF:
235
240
  """Return the bounding rect encompassing all visual bond representations."""
236
241
  try:
@@ -239,10 +244,10 @@ class BondItem(QGraphicsItem):
239
244
  line = QLineF(0, 0, 0, 0)
240
245
 
241
246
  # Get dynamic bond offset (spacing)
242
- scene = self.scene()
247
+ scene: Any = self.scene()
243
248
  bond_offset = 3.5
244
249
  wedge_width = 6.0
245
- if scene is not None and hasattr(scene, "get_setting"):
250
+ if scene is not None:
246
251
  key = (
247
252
  "bond_spacing_triple_2d"
248
253
  if getattr(self, "order", 1) == 3
@@ -251,7 +256,9 @@ class BondItem(QGraphicsItem):
251
256
  bond_offset = scene.get_setting(key, 3.5)
252
257
  wedge_width = scene.get_setting("bond_wedge_width_2d", 6.0)
253
258
 
259
+ # Qt prunes clicks whose shape() escapes boundingRect().
254
260
  extra = (getattr(self, "order", 1) - 1) * bond_offset + 50 + wedge_width
261
+ extra = max(extra, self.hit_radius() + 2.0)
255
262
  rect = (
256
263
  QRectF(line.p1(), line.p2())
257
264
  .normalized()
@@ -262,7 +269,7 @@ class BondItem(QGraphicsItem):
262
269
  if self.order == 2 and self.stereo in [3, 4]:
263
270
  font_size = 20
264
271
  font_family = FONT_FAMILY
265
- if scene is not None and hasattr(scene, "get_setting"):
272
+ if scene is not None:
266
273
  font_size = scene.get_setting("atom_font_size_2d", 20)
267
274
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
268
275
 
@@ -292,25 +299,9 @@ class BondItem(QGraphicsItem):
292
299
  path.moveTo(line.p1())
293
300
  path.lineTo(line.p2())
294
301
 
295
- # Use the exact same setting as atom hover highlight and snapping, zoom-aware.
296
- scene = self.scene()
297
- if scene and scene.views():
298
- view = scene.views()[0]
299
- scale = view.transform().m11()
300
- if scale <= 0.0:
301
- scale = 1.0
302
- if hasattr(scene, "get_setting"):
303
- hit_px = scene.get_setting("bond_snapping_distance_2d", 14.0)
304
- else:
305
- hit_px = 14.0
306
- # width is diameter, so 2 * radius
307
- scene_width = (hit_px * 2) / scale
308
- else:
309
- scene_width = 28.0
310
-
311
- # Stroke it to give it some width
302
+ # Stroke it to give it some width (width is a diameter, so 2 * radius)
312
303
  stroker = QPainterPathStroker()
313
- stroker.setWidth(scene_width)
304
+ stroker.setWidth(self.hit_radius() * 2.0)
314
305
  path = stroker.createStroke(path)
315
306
 
316
307
  # If there's an E/Z label, add its rect to the selection shape
@@ -362,8 +353,8 @@ class BondItem(QGraphicsItem):
362
353
  bond_color = QColor("#222222")
363
354
 
364
355
  try:
365
- scene = self.scene()
366
- if scene is not None and hasattr(scene, "get_setting"):
356
+ scene: Any = self.scene()
357
+ if scene is not None:
367
358
  # Color
368
359
  if self.isSelected():
369
360
  bond_color = QColor("blue")
@@ -538,8 +529,8 @@ class BondItem(QGraphicsItem):
538
529
  # --- Label Settings ---
539
530
  font_size = 20
540
531
  font_family = FONT_FAMILY
541
- _sc = self.scene()
542
- if _sc is not None and hasattr(_sc, "get_setting"):
532
+ _sc: Any = self.scene()
533
+ if _sc is not None:
543
534
  font_size = _sc.get_setting("atom_font_size_2d", 20)
544
535
  font_family = _sc.get_setting(
545
536
  "atom_font_family_2d", FONT_FAMILY
@@ -441,7 +441,10 @@ class BondLengthDialog(GeometryBaseDialog):
441
441
  if abs(worst_delta) < 1e-4:
442
442
  return
443
443
  with suppress_log(AttributeError, RuntimeError, TypeError):
444
- self.main_window.statusBar().showMessage(
444
+ status_bar = self.main_window.statusBar()
445
+ if status_bar is None:
446
+ return
447
+ status_bar.showMessage(
445
448
  f"Bond {worst_label} also changed by {worst_delta:+.3f} A: a bond "
446
449
  "inside a ring cannot be resized on its own.",
447
450
  5000,
@@ -51,8 +51,9 @@ _GROUP_ROTATION_RADIANS_PER_PIXEL = 0.008
51
51
  def _rodrigues_matrix(axis: np.ndarray, angle: float) -> np.ndarray:
52
52
  """Return the rotation matrix for `angle` radians about `axis` (Rodrigues)."""
53
53
  norm = float(np.linalg.norm(axis))
54
+ identity: np.ndarray = np.eye(3)
54
55
  if norm < 1e-6 or abs(angle) < 1e-6:
55
- return np.eye(3)
56
+ return identity
56
57
  unit = axis / norm
57
58
  k_mat = np.array(
58
59
  [
@@ -61,7 +62,10 @@ def _rodrigues_matrix(axis: np.ndarray, angle: float) -> np.ndarray:
61
62
  [-unit[1], unit[0], 0.0],
62
63
  ]
63
64
  )
64
- return np.eye(3) + np.sin(angle) * k_mat + (1.0 - np.cos(angle)) * (k_mat @ k_mat)
65
+ rotation: np.ndarray = (
66
+ identity + np.sin(angle) * k_mat + (1.0 - np.cos(angle)) * (k_mat @ k_mat)
67
+ )
68
+ return rotation
65
69
 
66
70
 
67
71
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
@@ -944,7 +948,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
944
948
  speed = _GROUP_ROTATION_RADIANS_PER_PIXEL * self._rotation_sensitivity()
945
949
  rot_y = _rodrigues_matrix(view_up, float(dx) * speed)
946
950
  rot_x = _rodrigues_matrix(right, -float(dy) * speed)
947
- return rot_x @ rot_y
951
+ combined: np.ndarray = rot_x @ rot_y
952
+ return combined
948
953
  except (AttributeError, RuntimeError, TypeError, ValueError):
949
954
  logging.debug("Suppressed non-critical error", exc_info=True)
950
955
  return None
@@ -489,7 +489,10 @@ class DihedralDialog(GeometryBaseDialog):
489
489
  if min(difference, 360.0 - difference) < 0.5:
490
490
  return
491
491
  with suppress_log(AttributeError, RuntimeError, TypeError):
492
- self.main_window.statusBar().showMessage(
492
+ status_bar = self.main_window.statusBar()
493
+ if status_bar is None:
494
+ return
495
+ status_bar.showMessage(
493
496
  f"Dihedral unchanged ({achieved:.2f}°): the four atoms rotate "
494
497
  "together, which happens for a bond inside a ring.",
495
498
  5000,
@@ -242,7 +242,7 @@ class Edit3DManager:
242
242
 
243
243
  # Position near top-right of atom
244
244
  atom_pos = atom_item.pos()
245
- atom_rect = atom_item.boundingRect()
245
+ atom_rect = atom_item.visual_rect()
246
246
  label_pos = QPointF(
247
247
  atom_pos.x() + atom_rect.width() / 4 + 2,
248
248
  atom_pos.y() - atom_rect.height() / 4 - 8,
@@ -1045,7 +1045,7 @@ class EditActionsManager:
1045
1045
  # Only prepare a geometry change if the implicit H count
1046
1046
  # changes (this may affect the item's bounding rect).
1047
1047
  need_geometry = current != new_count
1048
- if need_geometry and hasattr(item, "prepareGeometryChange"):
1048
+ if need_geometry:
1049
1049
  item.prepareGeometryChange()
1050
1050
  item.implicit_h_count = new_count
1051
1051
  item.has_problem = bool(desired_prob)
@@ -495,22 +495,20 @@ class IOManager:
495
495
  self.host.statusBar().showMessage("Error: Nothing to save.")
496
496
  return
497
497
 
498
- native_exts = [".pmeprj", ".pmeraw"]
499
- if self.host.init_manager.current_file_path and any(
500
- self.host.init_manager.current_file_path.lower().endswith(ext)
501
- for ext in native_exts
502
- ):
498
+ current_path = self.host.init_manager.current_file_path
499
+ if current_path and current_path.lower().endswith(".pmeraw"):
500
+ # Never write the legacy pickle back; Save As only emits .pmeprj.
501
+ self.host.update_status_message(
502
+ "Raw (.pmeraw) files are read-only — saving as .pmeprj instead."
503
+ )
504
+ self.save_project_as()
505
+ return
506
+
507
+ if current_path and current_path.lower().endswith(".pmeprj"):
503
508
  try:
504
- if self.host.init_manager.current_file_path.lower().endswith(".pmeraw"):
505
- save_data = self.host.state_manager.get_current_state()
506
- with open(self.host.init_manager.current_file_path, "wb") as f:
507
- pickle.dump(save_data, f)
508
- else:
509
- json_data = self.host.state_manager.create_json_data()
510
- with open(
511
- self.host.init_manager.current_file_path, "w", encoding="utf-8"
512
- ) as f:
513
- json.dump(json_data, f, indent=2, ensure_ascii=False)
509
+ json_data = self.host.state_manager.create_json_data()
510
+ with open(current_path, "w", encoding="utf-8") as f:
511
+ json.dump(json_data, f, indent=2, ensure_ascii=False)
514
512
 
515
513
  self.host.set_has_unsaved_changes(False)
516
514
  self.host.state_manager.update_window_title()
@@ -1109,6 +1107,25 @@ class IOManager:
1109
1107
  except (OSError, IOError, ValueError, RuntimeError, AttributeError) as e:
1110
1108
  self.host.statusBar().showMessage(f"Error saving XYZ: {e}")
1111
1109
 
1110
+ def _confirm_pickle_load(self, file_path: str) -> bool:
1111
+ """Ask before unpickling a .pmeraw file, which can run arbitrary code."""
1112
+ box = QMessageBox(self.host)
1113
+ box.setIcon(QMessageBox.Icon.Warning)
1114
+ box.setWindowTitle("Open Raw Project File?")
1115
+ box.setText("This file is in the legacy raw (pickle) format.")
1116
+ box.setInformativeText(
1117
+ f"{os.path.basename(file_path)}\n\n"
1118
+ "Raw project files can execute arbitrary code when opened. "
1119
+ "Only open .pmeraw files that you created yourself or received "
1120
+ "from a source you trust.\n\n"
1121
+ "The .pmeprj format is safe and is recommended instead."
1122
+ )
1123
+ open_button = box.addButton("Open", QMessageBox.ButtonRole.AcceptRole)
1124
+ cancel_button = box.addButton(QMessageBox.StandardButton.Cancel)
1125
+ box.setDefaultButton(cancel_button)
1126
+ box.exec()
1127
+ return box.clickedButton() is open_button
1128
+
1112
1129
  def load_raw_data(self, file_path: Optional[str] = None) -> None:
1113
1130
  """Open a .pmeraw pickle project file."""
1114
1131
  if not self.host.state_manager.check_unsaved_changes():
@@ -1129,6 +1146,10 @@ class IOManager:
1129
1146
  if not file_path:
1130
1147
  return
1131
1148
 
1149
+ if not self._confirm_pickle_load(file_path):
1150
+ self.host.update_status_message("Opening raw project file cancelled.")
1151
+ return
1152
+
1132
1153
  if not self.host.edit_actions_manager.clear_all(skip_check=True):
1133
1154
  return
1134
1155
 
@@ -223,7 +223,7 @@ class MainInitManager:
223
223
  """Resolve the atom-label font now, in the user's configured family."""
224
224
  scene = getattr(self, "scene", None)
225
225
  font = None
226
- if scene is not None and hasattr(scene, "get_setting"):
226
+ if scene is not None:
227
227
  family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
228
228
  size = scene.get_setting("atom_font_size_2d", 20)
229
229
  bold = scene.get_setting("atom_font_bold_2d", True)
@@ -1396,6 +1396,7 @@ class SceneQueryMixin:
1396
1396
  items: Any
1397
1397
  update_all_items: Any
1398
1398
  window: Any
1399
+ views: Any
1399
1400
  addItem: Any
1400
1401
  removeItem: Any
1401
1402
 
@@ -1592,20 +1593,15 @@ class SceneQueryMixin:
1592
1593
  def find_atom_near(self, pos: Any, tol: float = 14.0) -> Any:
1593
1594
  """Return the AtomItem within tolerance distance of pos, or None.
1594
1595
 
1595
- ``tol`` is specified in **screen pixels**. It is divided by the
1596
- current view scale so that the effective snap radius stays constant
1597
- on screen regardless of zoom level — matching the behaviour of
1598
- ``AtomItem.shape()`` (hover highlight).
1596
+ ``tol`` is in screen pixels, divided by the view scale to match shape().
1599
1597
  """
1600
1598
  if pos is None:
1601
1599
  return None
1602
1600
  # Convert screen-pixel tolerance to scene units (zoom-aware).
1603
1601
  if self.views():
1604
- xform = self.views()[0].transform()
1605
- if xform is not None:
1606
- scale = xform.m11()
1607
- if scale > 0:
1608
- tol = tol / scale
1602
+ scale = self.views()[0].transform().m11()
1603
+ if scale > 0:
1604
+ tol = tol / scale
1609
1605
  # Create a small search rectangle around the position
1610
1606
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1611
1607
  nearby_items = self.items(search_rect)
@@ -190,10 +190,11 @@ class Settings2DTab(SettingsTabBase):
190
190
 
191
191
  form_layout.addRow(self._create_separator())
192
192
 
193
- # --- Bond Snapping Settings ---
194
- form_layout.addRow(QLabel("<b>Bond Snapping Settings</b>"))
193
+ # --- Atom Hit / Snap Settings ---
194
+ form_layout.addRow(QLabel("<b>Atom Hit / Snap Settings</b>"))
195
195
 
196
- # Bond Snapping Distance
196
+ # Atom hit radius (also used for bond snapping) — internal key is
197
+ # kept as bond_snapping_distance_2d so existing settings files load.
197
198
  self.bond_snapping_distance_2d_slider, self.bond_snapping_distance_2d_label = (
198
199
  self._create_slider(5, 50, 1.0, is_int=True)
199
200
  )
@@ -201,10 +202,10 @@ class Settings2DTab(SettingsTabBase):
201
202
  "The distance in screen pixels (zoom-independent) within which:\n"
202
203
  "• Drawing a bond snaps to an existing atom\n"
203
204
  "• Keyboard shortcuts instantly edit the atom under cursor\n"
204
- "• Atoms show hover highlight"
205
+ "• Atoms and bonds show hover highlight and respond to clicks"
205
206
  )
206
207
  form_layout.addRow(
207
- "Bond Snapping Distance (px):",
208
+ "Atom Hit / Snap Distance (px):",
208
209
  self._wrap_layout(
209
210
  self.bond_snapping_distance_2d_slider,
210
211
  self.bond_snapping_distance_2d_label,
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from typing import Optional, cast
16
16
 
17
- from PyQt6.QtCore import QEvent, QPointF, Qt
17
+ from PyQt6.QtCore import QEvent, QPointF, Qt, QTimer
18
18
  from PyQt6.QtGui import QMouseEvent, QWheelEvent, QNativeGestureEvent
19
19
  from PyQt6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget
20
20
 
@@ -38,6 +38,14 @@ class ZoomableView(QGraphicsView):
38
38
  self._pan_start_scroll_h = 0
39
39
  self._pan_start_scroll_v = 0
40
40
 
41
+ # Coalesces re-indexing to once per zoom gesture; doing it per wheel
42
+ # tick dirties every item and costs ~40x the zoom step on a large
43
+ # structure.
44
+ self._geometry_timer = QTimer(self)
45
+ self._geometry_timer.setSingleShot(True)
46
+ self._geometry_timer.setInterval(50)
47
+ self._geometry_timer.timeout.connect(self._invalidate_item_geometry)
48
+
41
49
  def wheelEvent(self, event: Optional[QWheelEvent]) -> None:
42
50
  """Event handler for mouse wheel rotation"""
43
51
  if event is None:
@@ -123,6 +131,28 @@ class ZoomableView(QGraphicsView):
123
131
  else:
124
132
  super().mouseReleaseEvent(event)
125
133
 
134
+ def _invalidate_item_geometry(self) -> None:
135
+ """Re-index items: their pixel-sized hit geometry changes with zoom."""
136
+ scene = self.scene()
137
+ if scene is None:
138
+ return
139
+ for item in scene.items():
140
+ # A wrapper deleted mid-iteration (scene teardown) raises.
141
+ try:
142
+ item.prepareGeometryChange()
143
+ except RuntimeError:
144
+ continue
145
+
146
+ def scale(self, sx: float, sy: float) -> None:
147
+ """Scale the view, then re-index items once the gesture settles."""
148
+ super().scale(sx, sy)
149
+ self._geometry_timer.start()
150
+
151
+ def resetTransform(self) -> None:
152
+ """Reset the view transform and re-index items for the new scale."""
153
+ super().resetTransform()
154
+ self._geometry_timer.start()
155
+
126
156
  def viewportEvent(self, event: Optional[QEvent]) -> bool:
127
157
  """Handle native gestures (like pinch zoom on trackpads)"""
128
158
  if event is None:
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ from typing import Any
14
+
15
+ DEFAULT_HIT_PX = 14.0
16
+
17
+
18
+ def scene_hit_radius(scene: Any, fallback: float = DEFAULT_HIT_PX) -> float:
19
+ """Return the 2D hit/snap radius in scene units for the current zoom.
20
+
21
+ bond_snapping_distance_2d is in screen pixels, so it is divided by the view
22
+ scale; shape() and find_atom_near() share this to stay in step.
23
+ """
24
+ if not scene or not scene.views():
25
+ return fallback
26
+
27
+ scale = scene.views()[0].transform().m11()
28
+ if scale <= 0.0:
29
+ scale = 1.0
30
+
31
+ hit_px = scene.get_setting("bond_snapping_distance_2d", DEFAULT_HIT_PX)
32
+ return float(hit_px / scale)
File without changes