MoleditPy-linux 4.0.2__py3-none-any.whl → 4.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. moleditpy_linux/core/mol_geometry.py +24 -8
  2. moleditpy_linux/main.py +41 -6
  3. moleditpy_linux/plugins/plugin_interface.py +36 -33
  4. moleditpy_linux/plugins/plugin_manager_window.py +1 -1
  5. moleditpy_linux/ui/app_state.py +3 -7
  6. moleditpy_linux/ui/atom_item.py +50 -19
  7. moleditpy_linux/ui/bond_item.py +27 -23
  8. moleditpy_linux/ui/calculation_worker.py +20 -26
  9. moleditpy_linux/ui/compute_logic.py +3 -3
  10. moleditpy_linux/ui/constrained_optimization_dialog.py +1 -4
  11. moleditpy_linux/ui/edit_actions_logic.py +12 -5
  12. moleditpy_linux/ui/export_logic.py +2 -1
  13. moleditpy_linux/ui/io_logic.py +5 -5
  14. moleditpy_linux/ui/molecule_scene.py +22 -15
  15. moleditpy_linux/ui/periodic_table_dialog.py +8 -6
  16. moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +47 -0
  17. moleditpy_linux/ui/settings_tabs/settings_other_tab.py +33 -0
  18. moleditpy_linux/ui/user_template_dialog.py +1 -1
  19. moleditpy_linux/ui/view_3d_logic.py +0 -2
  20. moleditpy_linux/ui/zoomable_view.py +4 -0
  21. moleditpy_linux/utils/default_settings.py +5 -0
  22. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/METADATA +1 -1
  23. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/RECORD +27 -27
  24. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/WHEEL +0 -0
  25. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/entry_points.txt +0 -0
  26. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/licenses/LICENSE +0 -0
  27. {moleditpy_linux-4.0.2.dist-info → moleditpy_linux-4.1.0.dist-info}/top_level.txt +0 -0
@@ -13,7 +13,21 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
  import math
15
15
  from collections import deque
16
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
16
+ from typing import (
17
+ TYPE_CHECKING,
18
+ Any,
19
+ Callable,
20
+ Dict,
21
+ Iterable,
22
+ List,
23
+ Optional,
24
+ Set,
25
+ Tuple,
26
+ Union,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from rdkit import Chem
17
31
 
18
32
  import numpy as np
19
33
 
@@ -82,7 +96,7 @@ def calc_angle_deg(
82
96
 
83
97
 
84
98
  def get_connected_group(
85
- mol: Any, start_atom: int, exclude: Optional[int] = None
99
+ mol: Chem.Mol, start_atom: int, exclude: Optional[int] = None
86
100
  ) -> Set[int]:
87
101
  """Return the set of atom indices reachable from *start_atom*
88
102
  without passing through *exclude*.
@@ -422,7 +436,9 @@ def is_problematic_valence(
422
436
 
423
437
 
424
438
  def inject_ez_stereo_to_mol_block(
425
- mol_block: str, rdkit_mol: Any, bonds_data: Dict[Tuple[int, int], Any]
439
+ mol_block: str,
440
+ rdkit_mol: Chem.Mol,
441
+ bonds_data: Dict[Tuple[int, int], Dict[str, Any]],
426
442
  ) -> str:
427
443
  """Generate a modified MOL block with 'M CFG' lines for E/Z stereochemistry.
428
444
 
@@ -504,7 +520,7 @@ def identify_valence_problems(
504
520
  problem_atom_ids = []
505
521
 
506
522
  # Pre-calculate bond orders per atom
507
- bond_orders: Dict[int, int] = {}
523
+ bond_orders: Dict[int, float] = {}
508
524
  for (id1, id2), bond in bonds_data.items():
509
525
  order = bond.get("order", 1)
510
526
  bond_orders[id1] = bond_orders.get(id1, 0) + order
@@ -521,7 +537,7 @@ def identify_valence_problems(
521
537
  return problem_atom_ids
522
538
 
523
539
 
524
- def optimize_2d_coords(mol: Any) -> Dict[int, Tuple[float, float]]:
540
+ def optimize_2d_coords(mol: Chem.Mol) -> Dict[int, Tuple[float, float]]:
525
541
  """Generate 2D coordinates using RDKit and return a map of (x, y) tuples."""
526
542
  from rdkit.Chem import AllChem
527
543
 
@@ -572,7 +588,7 @@ def resolve_2d_overlaps(
572
588
  adjacency_list: Dict[int, List[int]],
573
589
  overlap_threshold: float = 0.5,
574
590
  move_distance: float = 20,
575
- has_bond_check_func: Optional[Any] = None,
591
+ has_bond_check_func: Optional[Callable[[int, int], bool]] = None,
576
592
  ) -> List[Tuple[Set[int], Tuple[float, float]]]:
577
593
  """Detect and resolve overlapping atom groups in 2D.
578
594
 
@@ -601,13 +617,13 @@ def resolve_2d_overlaps(
601
617
  # Union-Find for overlap groups
602
618
  parent = {aid: aid for aid in atom_ids}
603
619
 
604
- def find_set(aid: Any) -> Any:
620
+ def find_set(aid: int) -> int:
605
621
  if parent[aid] == aid:
606
622
  return aid
607
623
  parent[aid] = find_set(parent[aid])
608
624
  return parent[aid]
609
625
 
610
- def unite_sets(aid1: Any, aid2: Any) -> None:
626
+ def unite_sets(aid1: int, aid2: int) -> None:
611
627
  root1 = find_set(aid1)
612
628
  root2 = find_set(aid2)
613
629
  if root1 != root2:
moleditpy_linux/main.py CHANGED
@@ -11,10 +11,12 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import ctypes
14
- import sys
15
- import argparse
14
+ import json
16
15
  import logging
16
+ import logging.handlers
17
17
  import os
18
+ import sys
19
+ import argparse
18
20
  from typing import Any
19
21
 
20
22
  from .utils.constants import VERSION
@@ -46,22 +48,55 @@ def _qt_message_handler(mode: QtMsgType, _context: Any, message: str) -> None:
46
48
  logging.log(_QT_LOG_LEVEL.get(mode, logging.WARNING), "Qt: %s", message)
47
49
 
48
50
 
51
+ def _read_startup_log_settings() -> tuple[bool, bool]:
52
+ """Read log_to_file and log_level_debug from settings.json before Qt starts.
53
+
54
+ Returns (log_to_file, log_level_debug). Falls back to (False, False) on any error.
55
+ """
56
+ settings_path = os.path.join(os.path.expanduser("~"), ".moleditpy", "settings.json")
57
+ try:
58
+ with open(settings_path, encoding="utf-8") as f:
59
+ data = json.load(f)
60
+ return bool(data.get("log_to_file", False)), bool(
61
+ data.get("log_level_debug", False)
62
+ )
63
+ except (OSError, json.JSONDecodeError, ValueError):
64
+ return False, False
65
+
66
+
49
67
  def setup_logging() -> None:
50
68
  """Configure root logger and install a global unhandled-exception handler."""
69
+ log_to_file, log_level_debug = _read_startup_log_settings()
70
+ level = logging.DEBUG if log_level_debug else logging.INFO
71
+ fmt = "%(asctime)s [%(levelname)s] %(name)s (%(pathname)s:%(lineno)d): %(message)s"
72
+
51
73
  logging.basicConfig(
52
- level=logging.INFO,
53
- format="%(asctime)s [%(levelname)s] %(name)s (%(pathname)s:%(lineno)d): %(message)s",
74
+ level=level,
75
+ format=fmt,
54
76
  stream=sys.stdout,
55
77
  force=True,
56
78
  )
57
79
 
80
+ if log_to_file:
81
+ log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
82
+ try:
83
+ os.makedirs(log_dir, exist_ok=True)
84
+ log_path = os.path.join(log_dir, "moleditpy_linux.log")
85
+ fh = logging.handlers.RotatingFileHandler(
86
+ log_path, maxBytes=1_048_576, backupCount=3, encoding="utf-8"
87
+ )
88
+ fh.setLevel(level)
89
+ fh.setFormatter(logging.Formatter(fmt))
90
+ logging.getLogger().addHandler(fh)
91
+ logging.info("File logging enabled: %s", log_path)
92
+ except OSError as e:
93
+ logging.warning("Could not open log file: %s", e)
94
+
58
95
  def handle_exception(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
59
96
  """Log unhandled exceptions using the configured logging system."""
60
97
  if issubclass(exc_type, KeyboardInterrupt):
61
- # Allow keyboard interrupt to exit normally
62
98
  sys.__excepthook__(exc_type, exc_value, exc_traceback)
63
99
  return
64
-
65
100
  logging.error(
66
101
  "Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
67
102
  )
@@ -10,6 +10,7 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
+ import logging
13
14
  from typing import Any, Callable, List, Optional, Union
14
15
 
15
16
 
@@ -173,10 +174,11 @@ class PluginContext:
173
174
  """Switch the application UI layout to 3D viewer mode (Public API)."""
174
175
  mw = self.get_main_window()
175
176
  if mw is not None and hasattr(mw, "ui_manager"):
176
- if hasattr(mw.ui_manager, "enter_3d_viewer_mode"):
177
- mw.ui_manager.enter_3d_viewer_mode()
178
- elif hasattr(mw.ui_manager, "enter_3d_viewer_ui_mode"):
179
- mw.ui_manager.enter_3d_viewer_ui_mode()
177
+ fn = getattr(mw.ui_manager, "enter_3d_viewer_mode", None) or getattr(
178
+ mw.ui_manager, "enter_3d_viewer_ui_mode", None
179
+ )
180
+ if fn:
181
+ fn()
180
182
 
181
183
  def enter_3d_mode(self) -> None:
182
184
  """Switch UI layout to 3D viewer mode. Alias for enter_3d_viewer_mode."""
@@ -240,16 +242,11 @@ class PluginContext:
240
242
  """Force the 3D window to redraw using the current molecule."""
241
243
  mw = self.get_main_window()
242
244
  if mw and hasattr(mw, "view_3d_manager"):
243
- mol = getattr(mw.view_3d_manager, "current_mol", None)
245
+ mol = mw.view_3d_manager.current_mol
244
246
  if mol:
245
247
  mw.view_3d_manager.draw_molecule_3d(mol)
246
- else:
247
- # Also redraw/clear plotter if no molecule
248
- if (
249
- hasattr(mw.view_3d_manager, "plotter")
250
- and mw.view_3d_manager.plotter
251
- ):
252
- mw.view_3d_manager.plotter.render()
248
+ elif mw.view_3d_manager.plotter:
249
+ mw.view_3d_manager.plotter.render()
253
250
 
254
251
  def reset_3d_camera(self) -> None:
255
252
  """Zoom in and re-center the 3D viewport to fit the current molecule."""
@@ -370,7 +367,7 @@ class PluginContext:
370
367
  default: Value to return if the setting is not found.
371
368
  """
372
369
  mw = self.get_main_window()
373
- if mw and hasattr(mw, "init_manager") and hasattr(mw.init_manager, "settings"):
370
+ if mw and hasattr(mw, "init_manager"):
374
371
  namespaced = f"plugin.{self._plugin_name}.{key}"
375
372
  return mw.init_manager.settings.get(namespaced, default)
376
373
  return default
@@ -387,11 +384,10 @@ class PluginContext:
387
384
  value: Value to store (must be JSON-serializable).
388
385
  """
389
386
  mw = self.get_main_window()
390
- if mw and hasattr(mw, "init_manager") and hasattr(mw.init_manager, "settings"):
387
+ if mw and hasattr(mw, "init_manager"):
391
388
  namespaced = f"plugin.{self._plugin_name}.{key}"
392
389
  mw.init_manager.settings[namespaced] = value
393
- if hasattr(mw.init_manager, "settings_dirty"):
394
- mw.init_manager.settings_dirty = True
390
+ mw.init_manager.settings_dirty = True
395
391
 
396
392
  def mark_project_modified(self) -> None:
397
393
  """Mark the current project as having unsaved changes and update the window title."""
@@ -399,8 +395,9 @@ class PluginContext:
399
395
  if mw and hasattr(mw, "state_manager"):
400
396
  try:
401
397
  mw.state_manager.has_unsaved_changes = True
402
- if hasattr(mw.state_manager, "update_window_title"):
403
- mw.state_manager.update_window_title()
398
+ fn = getattr(mw.state_manager, "update_window_title", None)
399
+ if fn:
400
+ fn()
404
401
  except Exception:
405
402
  pass
406
403
 
@@ -414,14 +411,16 @@ class PluginContext:
414
411
  if mw is None:
415
412
  return
416
413
  if hasattr(mw, "state_manager"):
417
- if hasattr(mw.state_manager, "update_realtime_info"):
418
- mw.state_manager.update_realtime_info()
419
- if hasattr(mw.state_manager, "update_window_title"):
420
- mw.state_manager.update_window_title()
421
- if hasattr(mw, "edit_actions_manager") and hasattr(
422
- mw.edit_actions_manager, "update_undo_redo_actions"
423
- ):
424
- mw.edit_actions_manager.update_undo_redo_actions()
414
+ fn = getattr(mw.state_manager, "update_realtime_info", None)
415
+ if fn:
416
+ fn()
417
+ fn = getattr(mw.state_manager, "update_window_title", None)
418
+ if fn:
419
+ fn()
420
+ if hasattr(mw, "edit_actions_manager"):
421
+ fn = getattr(mw.edit_actions_manager, "update_undo_redo_actions", None)
422
+ if fn:
423
+ fn()
425
424
 
426
425
  def fit_3d_view(self) -> None:
427
426
  """Zoom and re-center the 3D viewport to fit the current molecule."""
@@ -489,8 +488,10 @@ class PluginContext:
489
488
  mw = self.get_main_window()
490
489
  if mw and hasattr(mw, "init_manager"):
491
490
  scene = getattr(mw.init_manager, "scene", None)
492
- if scene is not None and hasattr(scene, "update_all_items"):
493
- scene.update_all_items()
491
+ if scene is not None:
492
+ fn = getattr(scene, "update_all_items", None)
493
+ if fn:
494
+ fn()
494
495
 
495
496
  def load_from_smiles(self, smiles: str) -> None:
496
497
  """Add a molecule from a SMILES string to the 2D editor."""
@@ -518,6 +519,7 @@ class PluginContext:
518
519
 
519
520
  return "\n".join(xyz_lines)
520
521
  except Exception:
522
+ logging.debug("to_xyz_block failed", exc_info=True)
521
523
  return None
522
524
 
523
525
 
@@ -541,8 +543,8 @@ class Plugin3DController:
541
543
  v3d = self._get_v3d()
542
544
  if v3d:
543
545
  v3d.update_atom_color_override(atom_index, color_hex)
544
- if hasattr(self._mw, "plotter") and self._mw.plotter:
545
- self._mw.plotter.render()
546
+ if v3d.plotter:
547
+ v3d.plotter.render()
546
548
 
547
549
  def set_bond_color(self, bond_index: int, color_hex: str) -> None:
548
550
  """
@@ -555,8 +557,8 @@ class Plugin3DController:
555
557
  v3d = self._get_v3d()
556
558
  if v3d:
557
559
  v3d.update_bond_color_override(bond_index, color_hex)
558
- if hasattr(self._mw, "plotter") and self._mw.plotter:
559
- self._mw.plotter.render()
560
+ if v3d.plotter:
561
+ v3d.plotter.render()
560
562
 
561
563
  def set_bond_color_by_atoms(
562
564
  self, atom_idx1: int, atom_idx2: int, color_hex: str
@@ -569,7 +571,8 @@ class Plugin3DController:
569
571
  atom_idx2: Second RDKit atom index.
570
572
  color_hex: Hex string e.g., "#00FF00".
571
573
  """
572
- mol = getattr(self._mw, "current_mol", None)
574
+ v3d = self._get_v3d()
575
+ mol = v3d.current_mol if v3d else None
573
576
  if not mol:
574
577
  return
575
578
 
@@ -213,7 +213,7 @@ class PluginManagerWindow(QDialog):
213
213
  "Success",
214
214
  f"Removed '{plugin.get('name', 'Unknown')}'.",
215
215
  )
216
- except (AttributeError, RuntimeError, ValueError) as e:
216
+ except OSError as e:
217
217
  QMessageBox.critical(
218
218
  self, "Error", f"Failed to delete plugin: {e}"
219
219
  )
@@ -542,13 +542,9 @@ class StateManager:
542
542
  # 3D viewer mode
543
543
  is_3d_mode = json_data.get("is_3d_viewer_mode", False)
544
544
  # Restore last successful optimization method if present in file
545
- try:
546
- method = json_data.get("last_successful_optimization_method", None)
547
- with contextlib.suppress(AttributeError, RuntimeError, TypeError):
548
- self.host.set_last_successful_optimization_method(method)
549
- except (AttributeError, RuntimeError, TypeError):
550
- # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
551
- pass
545
+ method = json_data.get("last_successful_optimization_method", None)
546
+ with contextlib.suppress(AttributeError, RuntimeError, TypeError):
547
+ self.host.set_last_successful_optimization_method(method)
552
548
 
553
549
  # Plugin State Restoration (Phase 3)
554
550
  self._preserved_plugin_data = {} # Reset preserved data on new load
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
- from typing import Any, List, Optional
14
+ from typing import TYPE_CHECKING, Any, List, Optional
15
15
 
16
16
  from PyQt6.QtCore import QPointF, QRectF, Qt
17
17
  from PyQt6.QtGui import (
@@ -23,7 +23,12 @@ from PyQt6.QtGui import (
23
23
  QPainterPath,
24
24
  QPen,
25
25
  )
26
- from PyQt6.QtWidgets import QGraphicsItem, QWidget
26
+ from PyQt6.QtWidgets import (
27
+ QGraphicsItem,
28
+ QGraphicsSceneHoverEvent,
29
+ QStyleOptionGraphicsItem,
30
+ QWidget,
31
+ )
27
32
 
28
33
  from ..utils.constants import (
29
34
  ATOM_RADIUS,
@@ -34,6 +39,9 @@ from ..utils.constants import (
34
39
  )
35
40
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
36
41
 
42
+ if TYPE_CHECKING:
43
+ from .bond_item import BondItem
44
+
37
45
 
38
46
  class AtomItem(QGraphicsItem):
39
47
  """2D scene item representing a single atom in the molecule editor."""
@@ -46,7 +54,7 @@ class AtomItem(QGraphicsItem):
46
54
  self.symbol: str = symbol
47
55
  self.charge: int = charge
48
56
  self.radical: int = radical
49
- self.bonds: List[Any] = []
57
+ self.bonds: List[BondItem] = []
50
58
  self.chiral_label: Optional[str] = None
51
59
 
52
60
  self.setPos(pos)
@@ -67,16 +75,24 @@ class AtomItem(QGraphicsItem):
67
75
  """Refresh font, color, and visibility based on current scene settings."""
68
76
  if sip_isdeleted_safe(self):
69
77
  return
70
- # Allow updating font preference dynamically
71
78
  font_size = 20
72
79
  font_family = FONT_FAMILY
80
+ font_bold = True
81
+ font_italic = False
82
+ font_underline = False
73
83
 
74
84
  scene = self.scene()
75
85
  if scene is not None and hasattr(scene, "get_setting"):
76
86
  font_size = scene.get_setting("atom_font_size_2d", 20)
77
87
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
78
-
79
- self.font = QFont(font_family, font_size, FONT_WEIGHT_BOLD)
88
+ font_bold = scene.get_setting("atom_font_bold_2d", True)
89
+ font_italic = scene.get_setting("atom_font_italic_2d", False)
90
+ font_underline = scene.get_setting("atom_font_underline_2d", False)
91
+
92
+ weight = QFont.Weight.Bold if font_bold else QFont.Weight.Normal
93
+ self.font = QFont(font_family, font_size, weight)
94
+ self.font.setItalic(font_italic)
95
+ self.font.setUnderline(font_underline)
80
96
  self.prepareGeometryChange()
81
97
 
82
98
  self.is_visible = not (
@@ -89,16 +105,23 @@ class AtomItem(QGraphicsItem):
89
105
 
90
106
  def boundingRect(self) -> QRectF:
91
107
  """Calculate the bounding rectangle for the atom item."""
92
- # --- Calculate text position and size using logic matching paint() ---
93
- # Get dynamic font size and family
94
108
  font_size = 20
95
109
  font_family = FONT_FAMILY
110
+ font_bold = True
111
+ font_italic = False
112
+ font_underline = False
96
113
  scene = self.scene()
97
114
  if scene is not None and hasattr(scene, "get_setting"):
98
115
  font_size = scene.get_setting("atom_font_size_2d", 20)
99
116
  font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
100
-
101
- font = QFont(font_family, font_size, FONT_WEIGHT_BOLD)
117
+ font_bold = scene.get_setting("atom_font_bold_2d", True)
118
+ font_italic = scene.get_setting("atom_font_italic_2d", False)
119
+ font_underline = scene.get_setting("atom_font_underline_2d", False)
120
+
121
+ weight = QFont.Weight.Bold if font_bold else QFont.Weight.Normal
122
+ font = QFont(font_family, font_size, weight)
123
+ font.setItalic(font_italic)
124
+ font.setUnderline(font_underline)
102
125
  fm = QFontMetricsF(font)
103
126
 
104
127
  hydrogen_part = ""
@@ -207,13 +230,21 @@ class AtomItem(QGraphicsItem):
207
230
 
208
231
  font_size = 20
209
232
  font_family = FONT_FAMILY
233
+ font_bold = True
234
+ font_italic = False
235
+ font_underline = False
210
236
  scene = self.scene()
211
- if scene is not None:
212
- if hasattr(scene, "get_setting"):
213
- font_size = scene.get_setting("atom_font_size_2d", 20)
214
- font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
215
-
216
- font = QFont(font_family, font_size, FONT_WEIGHT_BOLD)
237
+ if scene is not None and hasattr(scene, "get_setting"):
238
+ font_size = scene.get_setting("atom_font_size_2d", 20)
239
+ font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
240
+ font_bold = scene.get_setting("atom_font_bold_2d", True)
241
+ font_italic = scene.get_setting("atom_font_italic_2d", False)
242
+ font_underline = scene.get_setting("atom_font_underline_2d", False)
243
+
244
+ weight = QFont.Weight.Bold if font_bold else QFont.Weight.Normal
245
+ font = QFont(font_family, font_size, weight)
246
+ font.setItalic(font_italic)
247
+ font.setUnderline(font_underline)
217
248
  fm = QFontMetricsF(font)
218
249
 
219
250
  hydrogen_part = ""
@@ -292,7 +323,7 @@ class AtomItem(QGraphicsItem):
292
323
  def paint(
293
324
  self,
294
325
  painter: Optional[QPainter],
295
- option: Any,
326
+ option: QStyleOptionGraphicsItem,
296
327
  widget: Optional[QWidget] = None,
297
328
  ) -> None:
298
329
  """Paint the atom symbol and its associated labels (charge, radical)."""
@@ -484,14 +515,14 @@ class AtomItem(QGraphicsItem):
484
515
  self.update_style()
485
516
  return res
486
517
 
487
- def hoverEnterEvent(self, event: Any) -> None:
518
+ def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None:
488
519
  """Highlight the atom on mouse hover."""
489
520
  # Enable highlight on hover regardless of scene mode
490
521
  self.hovered = True
491
522
  self.update()
492
523
  super().hoverEnterEvent(event)
493
524
 
494
- def hoverLeaveEvent(self, event: Any) -> None:
525
+ def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None:
495
526
  """Remove hover highlight when the mouse leaves."""
496
527
  if self.hovered:
497
528
  self.hovered = False
@@ -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 Any, Optional, Tuple, Union
16
+ from typing import TYPE_CHECKING, Optional, Tuple, Union
17
17
 
18
18
  from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt
19
19
  from PyQt6.QtGui import (
@@ -27,7 +27,13 @@ from PyQt6.QtGui import (
27
27
  QPen,
28
28
  QPolygonF,
29
29
  )
30
- from PyQt6.QtWidgets import QGraphicsItem, QGraphicsScene, QWidget
30
+ from PyQt6.QtWidgets import (
31
+ QGraphicsItem,
32
+ QGraphicsScene,
33
+ QGraphicsSceneHoverEvent,
34
+ QStyleOptionGraphicsItem,
35
+ QWidget,
36
+ )
31
37
 
32
38
  from ..utils.constants import (
33
39
  DESIRED_BOND_PIXEL_WIDTH,
@@ -39,6 +45,9 @@ from ..utils.constants import (
39
45
  HOVER_PEN_WIDTH,
40
46
  )
41
47
 
48
+ if TYPE_CHECKING:
49
+ from .atom_item import AtomItem
50
+
42
51
 
43
52
  class BondItem(QGraphicsItem):
44
53
  """Visual representation of a molecular bond in the 2D scene."""
@@ -138,14 +147,18 @@ class BondItem(QGraphicsItem):
138
147
  return min(0.9, 0.8 + 0.1 * openness)
139
148
 
140
149
  def __init__(
141
- self, atom1_item: Any, atom2_item: Any, order: int = 1, stereo: int = 0
150
+ self,
151
+ atom1_item: AtomItem,
152
+ atom2_item: AtomItem,
153
+ order: int = 1,
154
+ stereo: int = 0,
142
155
  ) -> None:
143
156
  super().__init__()
144
157
  # Validate input parameters
145
158
  if atom1_item is None or atom2_item is None:
146
159
  raise ValueError("BondItem requires non-None atom items")
147
- self.atom1: Any = atom1_item
148
- self.atom2: Any = atom2_item
160
+ self.atom1: Optional[AtomItem] = atom1_item
161
+ self.atom2: Optional[AtomItem] = atom2_item
149
162
  self.order: int = order
150
163
  self.stereo: int = stereo
151
164
 
@@ -314,7 +327,7 @@ class BondItem(QGraphicsItem):
314
327
  def paint(
315
328
  self,
316
329
  painter: Optional[QPainter],
317
- option: Any,
330
+ option: QStyleOptionGraphicsItem,
318
331
  widget: Optional[QWidget] = None,
319
332
  ) -> None:
320
333
  """Render the bond as single, double, triple, wedge, or dashed line."""
@@ -506,21 +519,12 @@ class BondItem(QGraphicsItem):
506
519
  # --- Label Settings ---
507
520
  font_size = 20
508
521
  font_family = FONT_FAMILY
509
- try:
510
- if self.scene() and self.scene().views():
511
- win = self.scene().views()[0].window()
512
- if win and hasattr(win, "settings"):
513
- font_size = win.settings.get(
514
- "atom_font_size_2d", 20
515
- )
516
- font_family = win.settings.get(
517
- "atom_font_family_2d", FONT_FAMILY
518
- )
519
- except (AttributeError, RuntimeError, TypeError, ValueError):
520
- # Silent failure for non-critical 2D atom font setting
521
- # If we can't get custom settings, we just use defaults.
522
- # Safe defensive fallback catching AttributeError, RuntimeError, TypeError, ValueError
523
- pass
522
+ _sc = self.scene()
523
+ if _sc is not None and hasattr(_sc, "get_setting"):
524
+ font_size = _sc.get_setting("atom_font_size_2d", 20)
525
+ font_family = _sc.get_setting(
526
+ "atom_font_family_2d", FONT_FAMILY
527
+ )
524
528
 
525
529
  font = QFont(font_family, font_size, FONT_WEIGHT_BOLD)
526
530
  font.setItalic(True)
@@ -598,7 +602,7 @@ class BondItem(QGraphicsItem):
598
602
  logging.exception("Error updating bond position")
599
603
  # Continue without crashing
600
604
 
601
- def hoverEnterEvent(self, event: Any) -> None:
605
+ def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None:
602
606
  """Highlight the bond on mouse hover."""
603
607
  self.hovered = True
604
608
  self.update()
@@ -606,7 +610,7 @@ class BondItem(QGraphicsItem):
606
610
  self.scene().set_hovered_item(self)
607
611
  super().hoverEnterEvent(event)
608
612
 
609
- def hoverLeaveEvent(self, event: Any) -> None:
613
+ def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None:
610
614
  """Remove hover highlight when the mouse leaves."""
611
615
  if self.hovered:
612
616
  self.hovered = False