MoleditPy-linux 4.6.0__tar.gz → 4.7.0__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.0 → moleditpy_linux-4.7.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  5. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/angle_dialog.py +4 -1
  6. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/atom_item.py +29 -28
  7. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/bond_item.py +17 -12
  8. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/bond_length_dialog.py +4 -1
  9. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/custom_interactor_style.py +8 -3
  10. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/dihedral_dialog.py +4 -1
  11. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/edit_3d_logic.py +1 -1
  12. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/edit_actions_logic.py +1 -1
  13. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/io_logic.py +36 -15
  14. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/main_window_init.py +1 -1
  15. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +14 -2
  16. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/molecule_scene.py +2 -2
  17. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +9 -5
  18. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/zoomable_view.py +31 -1
  19. moleditpy_linux-4.7.0/src/moleditpy_linux/utils/hit_radius.py +32 -0
  20. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/LICENSE +0 -0
  21. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/README.md +0 -0
  22. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/setup.cfg +0 -0
  23. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  24. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  25. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  26. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  27. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/__init__.py +0 -0
  28. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/__main__.py +0 -0
  29. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  30. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  31. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  32. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/assets/icon.png +0 -0
  33. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/core/__init__.py +0 -0
  34. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  35. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  36. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/main.py +0 -0
  37. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  38. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  39. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  40. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  41. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  42. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  43. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  44. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  45. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  46. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/app_state.py +0 -0
  47. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  48. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  49. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  50. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  51. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  52. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  53. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  54. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  55. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  56. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  57. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  58. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  59. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  60. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  61. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  62. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  63. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  64. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  65. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  66. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  67. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  68. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  69. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  70. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  71. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  72. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  73. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  74. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  75. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  76. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  77. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  79. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/utils/constants.py +0 -0
  80. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  81. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  82. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  83. {moleditpy_linux-4.6.0 → moleditpy_linux-4.7.0}/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.0
3
+ Version: 4.7.0
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.0"
8
+ version = "4.7.0"
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.0
3
+ Version: 4.7.0
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
@@ -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,
@@ -34,10 +34,10 @@ from PyQt6.QtWidgets import (
34
34
  from ..utils.constants import (
35
35
  ATOM_RADIUS,
36
36
  CPK_COLORS,
37
- DESIRED_ATOM_PIXEL_RADIUS,
38
37
  FONT_FAMILY,
39
38
  FONT_WEIGHT_BOLD,
40
39
  )
40
+ from ..utils.hit_radius import scene_hit_radius
41
41
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
42
42
 
43
43
  if TYPE_CHECKING:
@@ -112,8 +112,8 @@ class AtomItem(QGraphicsItem):
112
112
  font_italic = False
113
113
  font_underline = False
114
114
 
115
- scene = self.scene()
116
- if scene is not None and hasattr(scene, "get_setting"):
115
+ scene: Any = self.scene()
116
+ if scene is not None:
117
117
  font_size = scene.get_setting("atom_font_size_2d", 20)
118
118
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
119
119
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -134,15 +134,15 @@ class AtomItem(QGraphicsItem):
134
134
  )
135
135
  self.update()
136
136
 
137
- def boundingRect(self) -> QRectF:
138
- """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."""
139
139
  font_size = 20
140
140
  font_family = FONT_FAMILY
141
141
  font_bold = True
142
142
  font_italic = False
143
143
  font_underline = False
144
- scene = self.scene()
145
- if scene is not None and hasattr(scene, "get_setting"):
144
+ scene: Any = self.scene()
145
+ if scene is not None:
146
146
  font_size = scene.get_setting("atom_font_size_2d", 20)
147
147
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
148
148
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -252,6 +252,13 @@ class AtomItem(QGraphicsItem):
252
252
  # 3. Add final margins for selection highlights, etc.
253
253
  return full_visual_rect.adjusted(-3, -3, 3, 3)
254
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
+
255
262
  def get_bg_ellipse_path(self) -> QPainterPath:
256
263
  """Return the elliptical background path used for bond endpoint clipping."""
257
264
  path = QPainterPath()
@@ -263,8 +270,8 @@ class AtomItem(QGraphicsItem):
263
270
  font_bold = True
264
271
  font_italic = False
265
272
  font_underline = False
266
- scene = self.scene()
267
- if scene is not None and hasattr(scene, "get_setting"):
273
+ scene: Any = self.scene()
274
+ if scene is not None:
268
275
  font_size = scene.get_setting("atom_font_size_2d", 20)
269
276
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
270
277
  font_bold = scene.get_setting("atom_font_bold_2d", True)
@@ -331,19 +338,13 @@ class AtomItem(QGraphicsItem):
331
338
  path.addEllipse(bg_rect)
332
339
  return path
333
340
 
334
- def shape(self) -> QPainterPath:
335
- """Define the shape of the atom item for collision detection."""
336
- scene = self.scene()
337
- if not scene or not scene.views():
338
- path = QPainterPath()
339
- hit_r = max(4.0, ATOM_RADIUS - 6.0) * 2
340
- path.addEllipse(QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0))
341
- return path
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)
342
344
 
343
- view = scene.views()[0]
344
- scale = view.transform().m11()
345
-
346
- scene_radius = DESIRED_ATOM_PIXEL_RADIUS / 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()
347
348
 
348
349
  path = QPainterPath()
349
350
  path.addEllipse(QPointF(0, 0), scene_radius, scene_radius)
@@ -361,11 +362,11 @@ class AtomItem(QGraphicsItem):
361
362
  # Color logic: check if we should use bond color (uniform) or CPK (element-specific)
362
363
  color = CPK_COLORS.get(self.symbol, CPK_COLORS["DEFAULT"])
363
364
  # Use bond color if specified in settings
364
- scene = self.scene()
365
- if hasattr(scene, "get_setting") and (
366
- 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)
367
368
  ):
368
- custom_color = scene.get_setting("bond_color_2d", "#222222") # type: ignore[union-attr]
369
+ custom_color = scene.get_setting("bond_color_2d", "#222222")
369
370
  if isinstance(custom_color, str):
370
371
  color = QColor(custom_color)
371
372
 
@@ -520,17 +521,17 @@ class AtomItem(QGraphicsItem):
520
521
  if self.has_problem:
521
522
  painter.setBrush(Qt.BrushStyle.NoBrush)
522
523
  painter.setPen(QPen(QColor(255, 0, 0, 200), 4))
523
- painter.drawRect(self.boundingRect())
524
+ painter.drawRect(self.visual_rect())
524
525
  elif self.isSelected():
525
526
  painter.setBrush(Qt.BrushStyle.NoBrush)
526
527
  painter.setPen(QPen(QColor(0, 100, 255), 3))
527
- painter.drawRect(self.boundingRect())
528
+ painter.drawRect(self.visual_rect())
528
529
  if (not self.isSelected()) and getattr(self, "hovered", False):
529
530
  pen = QPen(QColor(144, 238, 144, 200), 3)
530
531
  pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
531
532
  painter.setBrush(Qt.BrushStyle.NoBrush)
532
533
  painter.setPen(pen)
533
- painter.drawRect(self.boundingRect())
534
+ painter.drawRect(self.visual_rect())
534
535
 
535
536
  def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
536
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 (
@@ -36,7 +36,6 @@ from PyQt6.QtWidgets import (
36
36
  )
37
37
 
38
38
  from ..utils.constants import (
39
- DESIRED_BOND_PIXEL_WIDTH,
40
39
  EZ_LABEL_BOX_SIZE,
41
40
  EZ_LABEL_MARGIN,
42
41
  EZ_LABEL_TEXT_OUTLINE,
@@ -44,6 +43,7 @@ from ..utils.constants import (
44
43
  FONT_WEIGHT_BOLD,
45
44
  HOVER_PEN_WIDTH,
46
45
  )
46
+ from ..utils.hit_radius import scene_hit_radius
47
47
 
48
48
  if TYPE_CHECKING:
49
49
  from .atom_item import AtomItem
@@ -232,6 +232,10 @@ class BondItem(QGraphicsItem):
232
232
  # return zero line to prevent downstream crashes.
233
233
  return QLineF(0, 0, 0, 0)
234
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
+
235
239
  def boundingRect(self) -> QRectF:
236
240
  """Return the bounding rect encompassing all visual bond representations."""
237
241
  try:
@@ -240,10 +244,10 @@ class BondItem(QGraphicsItem):
240
244
  line = QLineF(0, 0, 0, 0)
241
245
 
242
246
  # Get dynamic bond offset (spacing)
243
- scene = self.scene()
247
+ scene: Any = self.scene()
244
248
  bond_offset = 3.5
245
249
  wedge_width = 6.0
246
- if scene is not None and hasattr(scene, "get_setting"):
250
+ if scene is not None:
247
251
  key = (
248
252
  "bond_spacing_triple_2d"
249
253
  if getattr(self, "order", 1) == 3
@@ -252,7 +256,9 @@ class BondItem(QGraphicsItem):
252
256
  bond_offset = scene.get_setting(key, 3.5)
253
257
  wedge_width = scene.get_setting("bond_wedge_width_2d", 6.0)
254
258
 
259
+ # Qt prunes clicks whose shape() escapes boundingRect().
255
260
  extra = (getattr(self, "order", 1) - 1) * bond_offset + 50 + wedge_width
261
+ extra = max(extra, self.hit_radius() + 2.0)
256
262
  rect = (
257
263
  QRectF(line.p1(), line.p2())
258
264
  .normalized()
@@ -263,7 +269,7 @@ class BondItem(QGraphicsItem):
263
269
  if self.order == 2 and self.stereo in [3, 4]:
264
270
  font_size = 20
265
271
  font_family = FONT_FAMILY
266
- if scene is not None and hasattr(scene, "get_setting"):
272
+ if scene is not None:
267
273
  font_size = scene.get_setting("atom_font_size_2d", 20)
268
274
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
269
275
 
@@ -293,10 +299,9 @@ class BondItem(QGraphicsItem):
293
299
  path.moveTo(line.p1())
294
300
  path.lineTo(line.p2())
295
301
 
296
- # Stroke it to give it some width (e.g., 10px or dynamic based on settings) generally easier to click
297
- # even if the visual width is smaller.
302
+ # Stroke it to give it some width (width is a diameter, so 2 * radius)
298
303
  stroker = QPainterPathStroker()
299
- stroker.setWidth(DESIRED_BOND_PIXEL_WIDTH) # Use constant (20.0)
304
+ stroker.setWidth(self.hit_radius() * 2.0)
300
305
  path = stroker.createStroke(path)
301
306
 
302
307
  # If there's an E/Z label, add its rect to the selection shape
@@ -348,8 +353,8 @@ class BondItem(QGraphicsItem):
348
353
  bond_color = QColor("#222222")
349
354
 
350
355
  try:
351
- scene = self.scene()
352
- if scene is not None and hasattr(scene, "get_setting"):
356
+ scene: Any = self.scene()
357
+ if scene is not None:
353
358
  # Color
354
359
  if self.isSelected():
355
360
  bond_color = QColor("blue")
@@ -524,8 +529,8 @@ class BondItem(QGraphicsItem):
524
529
  # --- Label Settings ---
525
530
  font_size = 20
526
531
  font_family = FONT_FAMILY
527
- _sc = self.scene()
528
- if _sc is not None and hasattr(_sc, "get_setting"):
532
+ _sc: Any = self.scene()
533
+ if _sc is not None:
529
534
  font_size = _sc.get_setting("atom_font_size_2d", 20)
530
535
  font_family = _sc.get_setting(
531
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)
@@ -856,7 +856,10 @@ class KeyboardMixin:
856
856
  return
857
857
 
858
858
  view = self.views()[0]
859
- cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
859
+ # mapToScene expects viewport coordinates. Mapping to the view widget directly
860
+ # includes the widget's frame margins, introducing a cursor offset that gets
861
+ # magnified when zooming.
862
+ cursor_pos = view.mapToScene(view.viewport().mapFromGlobal(QCursor.pos()))
860
863
  transform = view.transform()
861
864
  key = event.key()
862
865
  modifiers = event.modifiers()
@@ -1393,6 +1396,7 @@ class SceneQueryMixin:
1393
1396
  items: Any
1394
1397
  update_all_items: Any
1395
1398
  window: Any
1399
+ views: Any
1396
1400
  addItem: Any
1397
1401
  removeItem: Any
1398
1402
 
@@ -1587,9 +1591,17 @@ class SceneQueryMixin:
1587
1591
  return False
1588
1592
 
1589
1593
  def find_atom_near(self, pos: Any, tol: float = 14.0) -> Any:
1590
- """Return the AtomItem within tolerance distance of pos, or None."""
1594
+ """Return the AtomItem within tolerance distance of pos, or None.
1595
+
1596
+ ``tol`` is in screen pixels, divided by the view scale to match shape().
1597
+ """
1591
1598
  if pos is None:
1592
1599
  return None
1600
+ # Convert screen-pixel tolerance to scene units (zoom-aware).
1601
+ if self.views():
1602
+ scale = self.views()[0].transform().m11()
1603
+ if scale > 0:
1604
+ tol = tol / scale
1593
1605
  # Create a small search rectangle around the position
1594
1606
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1595
1607
  nearby_items = self.items(search_rect)
@@ -1002,8 +1002,8 @@ class MoleculeScene(
1002
1002
  # Find the active view for this scene
1003
1003
  for view in self.views():
1004
1004
  if view.isVisible():
1005
- # Map global cursor position to scene coordinates
1006
- local_pos = view.mapFromGlobal(global_pos)
1005
+ # Map global cursor position to scene coordinates (via viewport)
1006
+ local_pos = view.viewport().mapFromGlobal(global_pos)
1007
1007
  scene_pos = view.mapToScene(local_pos)
1008
1008
 
1009
1009
  # If the mouse is within the viewport, trigger the preview update
@@ -190,18 +190,22 @@ 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
  )
200
201
  self.bond_snapping_distance_2d_slider.setToolTip(
201
- "The distance in pixels within which drawing a bond will snap to an existing atom."
202
+ "The distance in screen pixels (zoom-independent) within which:\n"
203
+ "• Drawing a bond snaps to an existing atom\n"
204
+ "• Keyboard shortcuts instantly edit the atom under cursor\n"
205
+ "• Atoms and bonds show hover highlight and respond to clicks"
202
206
  )
203
207
  form_layout.addRow(
204
- "Bond Snapping Distance (px):",
208
+ "Atom Hit / Snap Distance (px):",
205
209
  self._wrap_layout(
206
210
  self.bond_snapping_distance_2d_slider,
207
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