MoleditPy-linux 4.0.0__py3-none-any.whl → 4.0.2__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.
Files changed (43) hide show
  1. moleditpy_linux/core/molecular_data.py +9 -0
  2. moleditpy_linux/main.py +24 -6
  3. moleditpy_linux/plugins/plugin_manager.py +14 -0
  4. moleditpy_linux/plugins/plugin_manager_window.py +10 -0
  5. moleditpy_linux/ui/about_dialog.py +3 -0
  6. moleditpy_linux/ui/analysis_window.py +4 -0
  7. moleditpy_linux/ui/angle_dialog.py +3 -0
  8. moleditpy_linux/ui/app_state.py +6 -0
  9. moleditpy_linux/ui/atom_item.py +7 -0
  10. moleditpy_linux/ui/bond_item.py +6 -0
  11. moleditpy_linux/ui/bond_length_dialog.py +3 -0
  12. moleditpy_linux/ui/calculation_worker.py +2 -0
  13. moleditpy_linux/ui/color_settings_dialog.py +5 -0
  14. moleditpy_linux/ui/compute_logic.py +3 -0
  15. moleditpy_linux/ui/constrained_optimization_dialog.py +12 -0
  16. moleditpy_linux/ui/custom_interactor_style.py +2 -0
  17. moleditpy_linux/ui/custom_qt_interactor.py +2 -0
  18. moleditpy_linux/ui/dihedral_dialog.py +3 -0
  19. moleditpy_linux/ui/edit_actions_logic.py +7 -0
  20. moleditpy_linux/ui/export_logic.py +2 -0
  21. moleditpy_linux/ui/io_logic.py +2 -0
  22. moleditpy_linux/ui/main_window.py +3 -1
  23. moleditpy_linux/ui/main_window_init.py +5 -0
  24. moleditpy_linux/ui/mirror_dialog.py +1 -0
  25. moleditpy_linux/ui/molecular_scene_handler.py +6 -3
  26. moleditpy_linux/ui/molecule_scene.py +7 -0
  27. moleditpy_linux/ui/move_group_dialog.py +4 -0
  28. moleditpy_linux/ui/periodic_table_dialog.py +3 -0
  29. moleditpy_linux/ui/planarize_dialog.py +5 -0
  30. moleditpy_linux/ui/settings_dialog.py +8 -0
  31. moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +2 -0
  32. moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +4 -0
  33. moleditpy_linux/ui/settings_tabs/settings_other_tab.py +2 -0
  34. moleditpy_linux/ui/template_preview_item.py +8 -0
  35. moleditpy_linux/ui/ui_manager.py +6 -0
  36. moleditpy_linux/ui/user_template_dialog.py +1 -0
  37. {moleditpy_linux-4.0.0.dist-info → moleditpy_linux-4.0.2.dist-info}/METADATA +1 -1
  38. moleditpy_linux-4.0.2.dist-info/RECORD +75 -0
  39. moleditpy_linux-4.0.0.dist-info/RECORD +0 -75
  40. {moleditpy_linux-4.0.0.dist-info → moleditpy_linux-4.0.2.dist-info}/WHEEL +0 -0
  41. {moleditpy_linux-4.0.0.dist-info → moleditpy_linux-4.0.2.dist-info}/entry_points.txt +0 -0
  42. {moleditpy_linux-4.0.0.dist-info → moleditpy_linux-4.0.2.dist-info}/licenses/LICENSE +0 -0
  43. {moleditpy_linux-4.0.0.dist-info → moleditpy_linux-4.0.2.dist-info}/top_level.txt +0 -0
@@ -22,13 +22,17 @@ class PointTuple(tuple):
22
22
  """Backward-compatible tuple that allows .x() and .y() access like QPointF."""
23
23
 
24
24
  def x(self) -> float:
25
+ """Return the x coordinate as a float."""
25
26
  return float(self[0])
26
27
 
27
28
  def y(self) -> float:
29
+ """Return the y coordinate as a float."""
28
30
  return float(self[1])
29
31
 
30
32
 
31
33
  class MolecularData:
34
+ """In-memory graph of atoms and bonds, independent of any UI framework."""
35
+
32
36
  atoms: Dict[int, Dict[str, Any]]
33
37
  bonds: Dict[Tuple[int, int], Dict[str, Any]]
34
38
  adjacency_list: Dict[int, List[int]]
@@ -47,6 +51,7 @@ class MolecularData:
47
51
  charge: int = 0,
48
52
  radical: int = 0,
49
53
  ) -> int:
54
+ """Add a new atom and return its auto-assigned integer ID."""
50
55
  atom_id = self.next_atom_id
51
56
  # Internalize position as raw floats to decouple from UI types (QPointF)
52
57
  if hasattr(pos, "x") and hasattr(pos, "y"):
@@ -77,6 +82,7 @@ class MolecularData:
77
82
  def add_bond(
78
83
  self, id1: int, id2: int, order: Union[int, float] = 1, stereo: int = 0
79
84
  ) -> Tuple[Tuple[int, int], str]:
85
+ """Add or update a bond between two atoms; return its canonical key and 'created'/'updated'."""
80
86
  # For stereo bonds, do not sort because ID order determines direction.
81
87
  # For non-stereo bonds, sort to normalize the key.
82
88
  if stereo == 0:
@@ -100,6 +106,7 @@ class MolecularData:
100
106
  return (id1, id2), "created"
101
107
 
102
108
  def remove_atom(self, atom_id: int) -> None:
109
+ """Remove an atom and all bonds involving it from the data model."""
103
110
  if atom_id in self.atoms:
104
111
  # Safely get neighbors before deleting the atom's own entry
105
112
  neighbors = self.adjacency_list.get(atom_id, [])
@@ -123,6 +130,7 @@ class MolecularData:
123
130
  self.bonds.pop(key, None)
124
131
 
125
132
  def remove_bond(self, id1: int, id2: int) -> None:
133
+ """Remove the bond between two atoms, handling stereo and non-stereo key variants."""
126
134
  # Look for directional stereo bonds (forward/reverse) and normalized non-stereo bond keys.
127
135
  key_to_remove = None
128
136
  if (id1, id2) in self.bonds:
@@ -318,6 +326,7 @@ class MolecularData:
318
326
  return final_mol
319
327
 
320
328
  def to_mol_block(self) -> Optional[str]:
329
+ """Serialize the molecule to an MDL MOL block string, or None on failure."""
321
330
  mol = self.to_rdkit_mol()
322
331
  if mol:
323
332
  try:
moleditpy_linux/main.py CHANGED
@@ -21,12 +21,33 @@ from .utils.constants import VERSION
21
21
 
22
22
  # VERSION is resolved above (before Qt) so --version works without launching the app.
23
23
 
24
+ from PyQt6.QtCore import QtMsgType, qInstallMessageHandler
24
25
  from PyQt6.QtWidgets import QApplication
25
26
 
26
27
  from .ui.main_window import MainWindow
27
28
 
29
+ _QT_LOG_LEVEL = {
30
+ QtMsgType.QtDebugMsg: logging.DEBUG,
31
+ QtMsgType.QtInfoMsg: logging.INFO,
32
+ QtMsgType.QtWarningMsg: logging.WARNING,
33
+ QtMsgType.QtCriticalMsg: logging.ERROR,
34
+ QtMsgType.QtFatalMsg: logging.CRITICAL,
35
+ }
36
+
37
+ _DOWNGRADED_QT_PATTERNS = ("Retrying to obtain clipboard",)
38
+
39
+
40
+ def _qt_message_handler(mode: QtMsgType, _context: Any, message: str) -> None:
41
+ """Route Qt log messages to Python logging, downgrading known noisy warnings."""
42
+ for pattern in _DOWNGRADED_QT_PATTERNS:
43
+ if pattern in message:
44
+ logging.debug("Qt: %s", message)
45
+ return
46
+ logging.log(_QT_LOG_LEVEL.get(mode, logging.WARNING), "Qt: %s", message)
47
+
28
48
 
29
49
  def setup_logging() -> None:
50
+ """Configure root logger and install a global unhandled-exception handler."""
30
51
  logging.basicConfig(
31
52
  level=logging.INFO,
32
53
  format="%(asctime)s [%(levelname)s] %(name)s (%(pathname)s:%(lineno)d): %(message)s",
@@ -49,12 +70,11 @@ def setup_logging() -> None:
49
70
 
50
71
 
51
72
  def main() -> None:
52
- # Setup logging as early as possible
73
+ """Parse CLI arguments, configure logging, and launch the GUI."""
53
74
  setup_logging()
54
75
 
55
- # --- Additional handling for Windows taskbar icon ---
56
76
  if sys.platform == "win32":
57
- myappid = "hyoko.moleditpy_linux.1.0" # Application-specific ID (arbitrary)
77
+ myappid = "hyoko.moleditpy_linux.1.0"
58
78
  ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
59
79
 
60
80
  parser = argparse.ArgumentParser(
@@ -80,7 +100,6 @@ def main() -> None:
80
100
  # parse_known_args so Qt's own argv flags (e.g. -platform) are passed through
81
101
  args, remaining = parser.parse_known_args()
82
102
 
83
- # --- Headless Plugin Installation ---
84
103
  if args.install_plugin:
85
104
  plugin_path = os.path.abspath(args.install_plugin)
86
105
  if not os.path.exists(plugin_path):
@@ -95,7 +114,6 @@ def main() -> None:
95
114
  pm = PluginManager()
96
115
  sha256 = pm.compute_sha256(plugin_path)
97
116
 
98
- # Extract metadata
99
117
  metadata_file = plugin_path
100
118
  if os.path.isdir(plugin_path):
101
119
  init_py = os.path.join(plugin_path, "__init__.py")
@@ -136,10 +154,10 @@ def main() -> None:
136
154
  sys.exit(0)
137
155
 
138
156
  app = QApplication([sys.argv[0]] + remaining)
157
+ qInstallMessageHandler(_qt_message_handler)
139
158
  window = MainWindow(initial_file=args.file, safe_mode=args.safe)
140
159
  window.show()
141
160
 
142
- # Force Windows to refresh taskbar/titlebar icon after event loop starts
143
161
  if sys.platform == "win32":
144
162
  try:
145
163
  from PyQt6.QtCore import QTimer
@@ -29,6 +29,8 @@ from .plugin_interface import PluginContext
29
29
 
30
30
 
31
31
  class PluginManager:
32
+ """Discovers, loads, and manages lifecycle of all installed plugins."""
33
+
32
34
  def compute_sha256(self, path: str) -> str:
33
35
  """Computes SHA-256 for a file or a directory (concatenated hashes of all files)."""
34
36
  if os.path.isfile(path):
@@ -98,9 +100,11 @@ class PluginManager:
98
100
  ] = {} # Map of plugin_name -> {window_id -> window}
99
101
 
100
102
  def get_main_window(self) -> Any:
103
+ """Return the current main window reference."""
101
104
  return self.main_window
102
105
 
103
106
  def set_main_window(self, mw: Any) -> None:
107
+ """Set the main window reference."""
104
108
  self.main_window = mw
105
109
 
106
110
  def ensure_plugin_dir(self) -> None:
@@ -420,6 +424,7 @@ class PluginManager:
420
424
  icon: str,
421
425
  shortcut: str,
422
426
  ) -> None:
427
+ """Register a plugin menu action with its path, callback, and display metadata."""
423
428
  self.menu_actions.append(
424
429
  {
425
430
  "plugin": plugin_name,
@@ -434,6 +439,7 @@ class PluginManager:
434
439
  def register_toolbar_action(
435
440
  self, plugin_name: str, callback: Callable, text: str, icon: str, tooltip: str
436
441
  ) -> None:
442
+ """Register a plugin toolbar button with its callback and display metadata."""
437
443
  self.toolbar_actions.append(
438
444
  {
439
445
  "plugin": plugin_name,
@@ -447,6 +453,7 @@ class PluginManager:
447
453
  def register_drop_handler(
448
454
  self, plugin_name: str, callback: Callable, priority: int
449
455
  ) -> None:
456
+ """Register a drag-and-drop handler with a given priority."""
450
457
  self.drop_handlers.append(
451
458
  {"priority": priority, "plugin": plugin_name, "callback": callback}
452
459
  )
@@ -456,6 +463,7 @@ class PluginManager:
456
463
  def register_export_action(
457
464
  self, plugin_name: str, label: str, callback: Callable
458
465
  ) -> None:
466
+ """Register a plugin export action with its label and callback."""
459
467
  self.export_actions.append(
460
468
  {"plugin": plugin_name, "label": label, "callback": callback}
461
469
  )
@@ -463,6 +471,7 @@ class PluginManager:
463
471
  def register_optimization_method(
464
472
  self, plugin_name: str, method_name: str, callback: Callable
465
473
  ) -> None:
474
+ """Register a named 3D optimization method provided by a plugin."""
466
475
  # Key by upper-case method name for consistency
467
476
  self.optimization_methods[method_name.upper()] = {
468
477
  "plugin": plugin_name,
@@ -473,6 +482,7 @@ class PluginManager:
473
482
  def register_file_opener(
474
483
  self, plugin_name: str, extension: str, callback: Callable, priority: int = 0
475
484
  ) -> None:
485
+ """Register a file-type handler for the given extension."""
476
486
  # Normalize extension to lowercase
477
487
  ext = extension.lower()
478
488
  if not ext.startswith("."):
@@ -492,20 +502,24 @@ class PluginManager:
492
502
  def register_analysis_tool(
493
503
  self, plugin_name: str, label: str, callback: Callable
494
504
  ) -> None:
505
+ """Register a plugin analysis tool with its label and callback."""
495
506
  self.analysis_tools.append(
496
507
  {"plugin": plugin_name, "label": label, "callback": callback}
497
508
  )
498
509
 
499
510
  # State Persistence registration
500
511
  def register_save_handler(self, plugin_name: str, callback: Callable) -> None:
512
+ """Register a plugin session-save callback."""
501
513
  self.save_handlers[plugin_name] = callback
502
514
 
503
515
  def register_load_handler(self, plugin_name: str, callback: Callable) -> None:
516
+ """Register a plugin session-load callback."""
504
517
  self.load_handlers[plugin_name] = callback
505
518
 
506
519
  def register_3d_style(
507
520
  self, plugin_name: str, style_name: str, callback: Callable
508
521
  ) -> None:
522
+ """Register a named custom 3D rendering style."""
509
523
  self.custom_3d_styles[style_name] = {
510
524
  "plugin": plugin_name,
511
525
  "callback": callback,
@@ -33,6 +33,8 @@ from PyQt6.QtWidgets import (
33
33
 
34
34
 
35
35
  class PluginManagerWindow(QDialog):
36
+ """Dialog for browsing, installing, and removing plugins."""
37
+
36
38
  def __init__(self, plugin_manager: Any, parent: Optional[QWidget] = None) -> None:
37
39
  super().__init__(parent)
38
40
  self.btn_remove = None
@@ -46,6 +48,7 @@ class PluginManagerWindow(QDialog):
46
48
  self.refresh_plugin_list()
47
49
 
48
50
  def init_ui(self) -> None:
51
+ """Build the plugin manager UI with table, buttons, and drag-and-drop support."""
49
52
  layout = QVBoxLayout(self)
50
53
 
51
54
  lbl_info = QLabel("Drag & Drop .py or .zip files here to install plugins.")
@@ -106,6 +109,7 @@ class PluginManagerWindow(QDialog):
106
109
  layout.addLayout(btn_layout)
107
110
 
108
111
  def refresh_plugin_list(self) -> None:
112
+ """Repopulate the plugin table from the current plugin registry."""
109
113
  self.table.setRowCount(0)
110
114
  plugins = self.plugin_manager.plugins
111
115
 
@@ -146,11 +150,13 @@ class PluginManagerWindow(QDialog):
146
150
  self.table.item(row, 0).setForeground(color)
147
151
 
148
152
  def update_button_state(self) -> None:
153
+ """Enable or disable the Remove button based on table selection."""
149
154
  has_selection = self.table.currentRow() >= 0
150
155
  if hasattr(self, "btn_remove"):
151
156
  self.btn_remove.setEnabled(has_selection)
152
157
 
153
158
  def on_reload(self, silent: bool = False) -> None:
159
+ """Reload all plugins from disk and refresh the table."""
154
160
  # Trigger reload in main manager
155
161
  if self.plugin_manager.main_window:
156
162
  self.plugin_manager.discover_plugins(self.plugin_manager.main_window)
@@ -164,6 +170,7 @@ class PluginManagerWindow(QDialog):
164
170
  self.refresh_plugin_list()
165
171
 
166
172
  def on_remove_plugin(self) -> None:
173
+ """Delete the selected plugin file or folder and reload."""
167
174
  row = self.table.currentRow()
168
175
  if row < 0:
169
176
  QMessageBox.warning(self, "Warning", "Please select a plugin to remove.")
@@ -216,6 +223,7 @@ class PluginManagerWindow(QDialog):
216
223
  )
217
224
 
218
225
  def show_plugin_details(self, item: QTableWidgetItem) -> None:
226
+ """Show a message box with full metadata for the double-clicked plugin."""
219
227
  row = item.row()
220
228
  if row < len(self.plugin_manager.plugins):
221
229
  p = self.plugin_manager.plugins[row]
@@ -231,6 +239,7 @@ class PluginManagerWindow(QDialog):
231
239
 
232
240
  # --- Drag & Drop Support ---
233
241
  def dragEnterEvent(self, event: Optional[QDragEnterEvent]) -> None:
242
+ """Accept drag events carrying file URLs."""
234
243
  if event is None:
235
244
  return
236
245
  if event.mimeData().hasUrls():
@@ -239,6 +248,7 @@ class PluginManagerWindow(QDialog):
239
248
  event.ignore()
240
249
 
241
250
  def dropEvent(self, event: Optional[QDropEvent]) -> None:
251
+ """Install dropped plugin .py files, folders, or zip archives."""
242
252
  if event is None:
243
253
  return
244
254
  files_installed = []
@@ -30,6 +30,8 @@ from ..utils.constants import VERSION
30
30
 
31
31
 
32
32
  class AboutDialog(QDialog):
33
+ """Dialog showing application version, icon, and project links."""
34
+
33
35
  def __init__(self, main_window: Any, parent: Optional[QWidget] = None) -> None:
34
36
  super().__init__(parent)
35
37
  self.image_label = None
@@ -39,6 +41,7 @@ class AboutDialog(QDialog):
39
41
  self.init_ui()
40
42
 
41
43
  def init_ui(self) -> None:
44
+ """Build the about dialog layout with icon, version text, and links."""
42
45
  layout = QVBoxLayout(self)
43
46
 
44
47
  # Create a clickable image label
@@ -33,6 +33,8 @@ from rdkit.Chem import inchi as rd_inchi
33
33
 
34
34
 
35
35
  class AnalysisWindow(QDialog):
36
+ """Dialog displaying molecular formula, weight, and other computed properties."""
37
+
36
38
  def __init__(
37
39
  self,
38
40
  mol: Chem.Mol,
@@ -47,6 +49,7 @@ class AnalysisWindow(QDialog):
47
49
  self.init_ui()
48
50
 
49
51
  def init_ui(self) -> None:
52
+ """Build the analysis layout with computed molecular properties."""
50
53
  main_layout = QVBoxLayout(self)
51
54
  grid_layout = QGridLayout()
52
55
 
@@ -230,6 +233,7 @@ class AnalysisWindow(QDialog):
230
233
  self.setLayout(main_layout)
231
234
 
232
235
  def copy_to_clipboard(self, text: str) -> None:
236
+ """Copy text to the system clipboard and show a status bar message."""
233
237
  clipboard = QApplication.clipboard()
234
238
  clipboard.setText(text)
235
239
  if self.parent() and hasattr(self.parent(), "statusBar"):
@@ -40,6 +40,8 @@ if TYPE_CHECKING:
40
40
 
41
41
 
42
42
  class AngleDialog(GeometryBaseDialog):
43
+ """Dialog for interactively viewing and adjusting a bond angle."""
44
+
43
45
  def __init__(
44
46
  self,
45
47
  mol: Chem.Mol,
@@ -73,6 +75,7 @@ class AngleDialog(GeometryBaseDialog):
73
75
  self.init_ui()
74
76
 
75
77
  def init_ui(self) -> None:
78
+ """Build the angle adjustment UI with atom picker, slider, and input field."""
76
79
  self.setWindowTitle("Adjust Angle")
77
80
  self.setModal(False)
78
81
  layout = QVBoxLayout(self)
@@ -71,6 +71,8 @@ def _deserialize_constraints(raw: list) -> list:
71
71
 
72
72
 
73
73
  class StateManager:
74
+ """Manages serialized undo/redo state and document save state for MainWindow."""
75
+
74
76
  def __init__(self, host: MainWindow) -> None:
75
77
  self.host = host
76
78
  self.data: MolecularData # Dynamically assigned in main_window_init.py
@@ -80,6 +82,7 @@ class StateManager:
80
82
  self.saved_state: Optional[Dict[str, Any]] = None
81
83
 
82
84
  def get_current_state(self) -> Dict[str, Any]:
85
+ """Snapshot the current document into a serializable state dict."""
83
86
  atoms = {
84
87
  atom_id: {
85
88
  "symbol": data["symbol"],
@@ -126,6 +129,7 @@ class StateManager:
126
129
  return state
127
130
 
128
131
  def set_state_from_data(self, state_data: Dict[str, Any]) -> None:
132
+ """Restore the document from a previously captured state dict."""
129
133
  self.dragged_atom_info = None
130
134
  self.host.edit_actions_manager.clear_2d_editor(push_to_undo=False)
131
135
 
@@ -256,6 +260,7 @@ class StateManager:
256
260
  self.host.edit_3d_manager.update_2d_measurement_labels()
257
261
 
258
262
  def push_undo_state(self) -> None:
263
+ """Push the current state onto the undo stack."""
259
264
  self.host.edit_actions_manager.push_undo_state()
260
265
 
261
266
  def update_window_title(self) -> None:
@@ -307,6 +312,7 @@ class StateManager:
307
312
  return False # Cancel
308
313
 
309
314
  def reset_undo_stack(self) -> None:
315
+ """Clear the undo and redo stacks."""
310
316
  self.host.reset_undo_redo_stacks()
311
317
 
312
318
  def update_realtime_info(self) -> None:
@@ -36,6 +36,8 @@ from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
36
36
 
37
37
 
38
38
  class AtomItem(QGraphicsItem):
39
+ """2D scene item representing a single atom in the molecule editor."""
40
+
39
41
  def __init__(
40
42
  self, atom_id: int, symbol: str, pos: QPointF, charge: int = 0, radical: int = 0
41
43
  ) -> None:
@@ -62,6 +64,7 @@ class AtomItem(QGraphicsItem):
62
64
  self.font: QFont = QFont(FONT_FAMILY, 20, FONT_WEIGHT_BOLD)
63
65
 
64
66
  def update_style(self) -> None:
67
+ """Refresh font, color, and visibility based on current scene settings."""
65
68
  if sip_isdeleted_safe(self):
66
69
  return
67
70
  # Allow updating font preference dynamically
@@ -197,6 +200,7 @@ class AtomItem(QGraphicsItem):
197
200
  return full_visual_rect.adjusted(-3, -3, 3, 3)
198
201
 
199
202
  def get_bg_ellipse_path(self) -> QPainterPath:
203
+ """Return the elliptical background path used for bond endpoint clipping."""
200
204
  path = QPainterPath()
201
205
  if not self.is_visible:
202
206
  return path
@@ -468,6 +472,7 @@ class AtomItem(QGraphicsItem):
468
472
  painter.drawRect(self.boundingRect())
469
473
 
470
474
  def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
475
+ """Propagate position and scene changes to connected bond items."""
471
476
  res = super().itemChange(change, value)
472
477
  if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
473
478
  if self.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable:
@@ -480,12 +485,14 @@ class AtomItem(QGraphicsItem):
480
485
  return res
481
486
 
482
487
  def hoverEnterEvent(self, event: Any) -> None:
488
+ """Highlight the atom on mouse hover."""
483
489
  # Enable highlight on hover regardless of scene mode
484
490
  self.hovered = True
485
491
  self.update()
486
492
  super().hoverEnterEvent(event)
487
493
 
488
494
  def hoverLeaveEvent(self, event: Any) -> None:
495
+ """Remove hover highlight when the mouse leaves."""
489
496
  if self.hovered:
490
497
  self.hovered = False
491
498
  self.update()
@@ -159,6 +159,7 @@ class BondItem(QGraphicsItem):
159
159
  self.ring_center: Optional[Union[QPointF, Tuple[float, float]]] = None
160
160
 
161
161
  def get_line_in_local_coords(self) -> QLineF:
162
+ """Return the visible bond line trimmed back to avoid overlapping atom symbol backgrounds."""
162
163
  if self.atom1 is None or self.atom2 is None:
163
164
  return QLineF(0, 0, 0, 0)
164
165
  try:
@@ -214,6 +215,7 @@ class BondItem(QGraphicsItem):
214
215
  return QLineF(0, 0, 0, 0)
215
216
 
216
217
  def boundingRect(self) -> QRectF:
218
+ """Return the bounding rect encompassing all visual bond representations."""
217
219
  try:
218
220
  line = self.get_line_in_local_coords()
219
221
  except (AttributeError, RuntimeError, ValueError, TypeError):
@@ -315,6 +317,7 @@ class BondItem(QGraphicsItem):
315
317
  option: Any,
316
318
  widget: Optional[QWidget] = None,
317
319
  ) -> None:
320
+ """Render the bond as single, double, triple, wedge, or dashed line."""
318
321
  if painter is None or self.atom1 is None or self.atom2 is None:
319
322
  return
320
323
  line = self.get_line_in_local_coords()
@@ -584,6 +587,7 @@ class BondItem(QGraphicsItem):
584
587
  painter.restore()
585
588
 
586
589
  def update_position(self, notify: bool = True) -> None:
590
+ """Reposition the bond item relative to its first atom's scene position."""
587
591
  try:
588
592
  if notify:
589
593
  self.prepareGeometryChange()
@@ -595,6 +599,7 @@ class BondItem(QGraphicsItem):
595
599
  # Continue without crashing
596
600
 
597
601
  def hoverEnterEvent(self, event: Any) -> None:
602
+ """Highlight the bond on mouse hover."""
598
603
  self.hovered = True
599
604
  self.update()
600
605
  if self.scene():
@@ -602,6 +607,7 @@ class BondItem(QGraphicsItem):
602
607
  super().hoverEnterEvent(event)
603
608
 
604
609
  def hoverLeaveEvent(self, event: Any) -> None:
610
+ """Remove hover highlight when the mouse leaves."""
605
611
  if self.hovered:
606
612
  self.hovered = False
607
613
  self.update()
@@ -37,6 +37,8 @@ if TYPE_CHECKING:
37
37
 
38
38
 
39
39
  class BondLengthDialog(GeometryBaseDialog):
40
+ """Dialog for interactively viewing and adjusting a bond length."""
41
+
40
42
  def __init__(
41
43
  self,
42
44
  mol: Chem.Mol,
@@ -68,6 +70,7 @@ class BondLengthDialog(GeometryBaseDialog):
68
70
  self.init_ui()
69
71
 
70
72
  def init_ui(self) -> None:
73
+ """Build the bond length adjustment UI with atom picker, slider, and input field."""
71
74
  self.setWindowTitle("Adjust Bond Length")
72
75
  self.setModal(False)
73
76
 
@@ -696,6 +696,8 @@ print(ob_mol.write("mol"))
696
696
 
697
697
 
698
698
  class CalculationWorker(QObject):
699
+ """QObject worker that runs 3D geometry optimization on a background QThread."""
700
+
699
701
  status_update = pyqtSignal(str)
700
702
  finished = pyqtSignal(object)
701
703
  error = pyqtSignal(object)
@@ -240,6 +240,7 @@ class ColorSettingsDialog(QDialog):
240
240
  layout.addLayout(h)
241
241
 
242
242
  def on_element_clicked(self) -> None:
243
+ """Open a color picker for the clicked element button and update its swatch."""
243
244
  btn = self.sender()
244
245
  symbol = btn.text()
245
246
  cur = self.current_settings.get("cpk_colors", {}).get(symbol)
@@ -257,6 +258,7 @@ class ColorSettingsDialog(QDialog):
257
258
  )
258
259
 
259
260
  def reset_all(self) -> None:
261
+ """Revert all CPK and ball-and-stick colors to their defaults."""
260
262
  self.changed_cpk = {}
261
263
  self._reset_all_flag = True
262
264
 
@@ -284,6 +286,7 @@ class ColorSettingsDialog(QDialog):
284
286
  self.bs_button.setToolTip(hexv)
285
287
 
286
288
  def apply_changes(self) -> None:
289
+ """Write changed color settings to the application settings and redraw."""
287
290
  if not self.parent_window or not hasattr(self.parent_window, "init_manager"):
288
291
  return
289
292
 
@@ -370,10 +373,12 @@ class ColorSettingsDialog(QDialog):
370
373
  self.parent_window.view_3d_manager.draw_molecule_3d(mol)
371
374
 
372
375
  def accept(self) -> None:
376
+ """Apply changes and close the dialog."""
373
377
  self.apply_changes()
374
378
  super().accept()
375
379
 
376
380
  def pick_bs_bond_color(self) -> None:
381
+ """Open a color picker for the ball-and-stick bond color."""
377
382
  settings = self.current_settings or {}
378
383
  cur = getattr(self, "changed_bs_color", None) or settings.get(
379
384
  "ball_stick_bond_color"
@@ -450,6 +450,7 @@ class ComputeManager:
450
450
  def on_calculation_finished(
451
451
  self, result: Union[Chem.Mol, Tuple[int, Chem.Mol]]
452
452
  ) -> None:
453
+ """Handle a completed 3D optimization result from the background worker."""
453
454
  worker_id, mol = result if isinstance(result, tuple) else (None, result)
454
455
  if worker_id is not None:
455
456
  if worker_id not in self.active_worker_ids:
@@ -500,6 +501,7 @@ class ComputeManager:
500
501
  self.host.view_3d_manager.update_atom_id_menu_state()
501
502
 
502
503
  def on_calculation_error(self, message: Union[str, Tuple[int, str]]) -> None:
504
+ """Handle an error or halt signal from the background optimization worker."""
503
505
  # Accept either a string or (worker_id, message) tuple from the worker signal
504
506
  if isinstance(message, tuple) and len(message) == 2:
505
507
  worker_id, msg = message
@@ -567,6 +569,7 @@ class ComputeManager:
567
569
  continue
568
570
 
569
571
  def check_chemistry_problems_fallback(self) -> None:
572
+ """Mark valence-problem atoms in the 2D scene when RDKit validation is unavailable."""
570
573
  problem_atom_ids = identify_valence_problems(
571
574
  self.host.state_manager.data.atoms, self.host.state_manager.data.bonds
572
575
  )
@@ -34,6 +34,8 @@ from .dialog_3d_picking_mixin import Dialog3DPickingMixin
34
34
 
35
35
 
36
36
  class ConstrainedOptimizationThread(QThread):
37
+ """QThread that runs a force-field minimization with atom constraints."""
38
+
37
39
  optimization_finished = pyqtSignal()
38
40
  error_occurred = pyqtSignal(str)
39
41
 
@@ -43,6 +45,7 @@ class ConstrainedOptimizationThread(QThread):
43
45
  self.max_iters = max_iters
44
46
 
45
47
  def run(self) -> None:
48
+ """Execute force-field minimization and emit finished or error signal."""
46
49
  try:
47
50
  self.ff.Minimize(maxIts=self.max_iters)
48
51
  self.optimization_finished.emit()
@@ -156,6 +159,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
156
159
  logging.exception("Could not set default force field")
157
160
 
158
161
  def init_ui(self) -> None:
162
+ """Build the constrained optimization dialog with constraint table and controls."""
159
163
  self.setWindowTitle("Constrained Optimization")
160
164
  self.setModal(False)
161
165
  self.resize(450, 500)
@@ -256,6 +260,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
256
260
  self.update_selection_display()
257
261
 
258
262
  def update_selection_display(self) -> None:
263
+ """Update the selection label and Add Constraint button state."""
259
264
  self.show_selection_labels()
260
265
  n = len(self.selected_atoms)
261
266
 
@@ -292,6 +297,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
292
297
  self.add_button.setEnabled(can_add)
293
298
 
294
299
  def add_constraint(self) -> None:
300
+ """Read the current atom selection, compute its geometry value, and add a row to the table."""
295
301
  n = len(self.selected_atoms)
296
302
  conf = self.mol.GetConformer()
297
303
 
@@ -368,6 +374,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
368
374
  self.update_selection_display()
369
375
 
370
376
  def remove_constraint(self) -> None:
377
+ """Delete the selected constraint rows from the table."""
371
378
  selected_rows = sorted(
372
379
  list(set(index.row() for index in self.constraint_table.selectedIndexes())),
373
380
  reverse=True,
@@ -405,6 +412,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
405
412
  self.remove_button.setEnabled(False)
406
413
 
407
414
  def show_constraint_labels(self) -> None:
415
+ """Display 3D atom labels for the selected constraint rows."""
408
416
  self.clear_constraint_labels()
409
417
  selected_items = self.constraint_table.selectedItems()
410
418
  if not selected_items:
@@ -474,6 +482,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
474
482
  logging.debug(f"Could not restore camera position: {e}")
475
483
 
476
484
  def clear_constraint_labels(self) -> None:
485
+ """Remove all constraint label actors from the 3D plotter."""
477
486
  for label_actor in self.constraint_labels:
478
487
  try:
479
488
  plotter = self.main_window.view_3d_manager.plotter
@@ -487,6 +496,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
487
496
  self.constraint_labels = []
488
497
 
489
498
  def apply_optimization(self) -> None:
499
+ """Run force-field minimization with the configured constraints."""
490
500
  if not self.mol or self.mol.GetNumConformers() == 0:
491
501
  QMessageBox.warning(self, "Error", "No valid 3D molecule found.")
492
502
  return
@@ -666,10 +676,12 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
666
676
  QMessageBox.critical(self, "Error", f"Optimization failed: {e}")
667
677
 
668
678
  def closeEvent(self, event: Any) -> None:
679
+ """Delegate window close to reject."""
669
680
  self.reject()
670
681
  event.accept()
671
682
 
672
683
  def reject(self) -> None:
684
+ """Clear labels, disable picking, and save constraints before closing."""
673
685
  self.clear_constraint_labels()
674
686
  self.clear_selection_labels()
675
687
  self.disable_picking()
@@ -27,6 +27,8 @@ from rdkit import Geometry
27
27
 
28
28
 
29
29
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
30
+ """VTK interactor style extending trackball-camera with 3D atom drag and measurement."""
31
+
30
32
  def __init__(self, main_window: Any = None, **kwargs: Any) -> None:
31
33
  super().__init__(**kwargs)
32
34
  self.main_window = main_window