MoleditPy-linux 4.5.1__tar.gz → 4.6.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 (82) hide show
  1. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/PKG-INFO +3 -2
  2. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/README.md +1 -0
  3. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/pyproject.toml +2 -2
  4. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/PKG-INFO +3 -2
  5. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  6. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/requires.txt +1 -1
  7. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/atom_item.py +31 -6
  8. moleditpy_linux-4.6.0/src/moleditpy_linux/ui/chain_mixin.py +178 -0
  9. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/main_window_init.py +91 -17
  10. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/molecule_scene.py +31 -1
  11. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_item.py +62 -1
  12. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/ui_manager.py +13 -1
  13. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/zoomable_view.py +1 -1
  14. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/system_utils.py +18 -2
  15. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/LICENSE +0 -0
  16. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/setup.cfg +0 -0
  17. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  18. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  19. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  20. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/__init__.py +0 -0
  21. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/__main__.py +0 -0
  22. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  23. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  24. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  25. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.png +0 -0
  26. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/__init__.py +0 -0
  27. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  28. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  29. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/main.py +0 -0
  30. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  31. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  32. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  33. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  34. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  35. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  36. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  37. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  38. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  39. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  40. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/app_state.py +0 -0
  41. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  42. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  43. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
  44. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  45. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  46. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  47. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  48. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  49. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  50. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  51. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  52. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  53. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  54. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  55. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  56. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  57. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  58. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
  59. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  60. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  61. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  62. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  64. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  68. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  69. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  70. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  71. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  72. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  73. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  74. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  75. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  76. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  77. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  79. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/constants.py +0 -0
  80. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  81. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  82. {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.1
3
+ Version: 4.6.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
@@ -697,7 +697,7 @@ Requires-Dist: numpy
697
697
  Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
698
  Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
699
  Requires-Dist: pyvista<0.49
700
- Requires-Dist: pyvistaqt<0.12
700
+ Requires-Dist: pyvistaqt<0.13
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Dynamic: license-file
703
703
 
@@ -742,6 +742,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
742
742
 
743
743
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
744
744
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
745
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
745
746
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
746
747
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
747
748
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -39,6 +39,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
39
39
 
40
40
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
41
41
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
42
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
42
43
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
43
44
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
44
45
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.5.1"
8
+ version = "4.6.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -37,7 +37,7 @@ dependencies = [
37
37
  "pyqt6 < 6.10; python_version == '3.9'",
38
38
  "pyqt6 < 6.12; python_version != '3.9'",
39
39
  "pyvista < 0.49",
40
- "pyvistaqt < 0.12",
40
+ "pyvistaqt < 0.13",
41
41
  "rdkit < 2026.4",
42
42
  ]
43
43
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.1
3
+ Version: 4.6.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
@@ -697,7 +697,7 @@ Requires-Dist: numpy
697
697
  Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
698
  Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
699
  Requires-Dist: pyvista<0.49
700
- Requires-Dist: pyvistaqt<0.12
700
+ Requires-Dist: pyvistaqt<0.13
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Dynamic: license-file
703
703
 
@@ -742,6 +742,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
742
742
 
743
743
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
744
744
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
745
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
745
746
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
746
747
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
747
748
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -34,6 +34,7 @@ src/moleditpy_linux/ui/base_picking_dialog.py
34
34
  src/moleditpy_linux/ui/bond_item.py
35
35
  src/moleditpy_linux/ui/bond_length_dialog.py
36
36
  src/moleditpy_linux/ui/calculation_worker.py
37
+ src/moleditpy_linux/ui/chain_mixin.py
37
38
  src/moleditpy_linux/ui/color_settings_dialog.py
38
39
  src/moleditpy_linux/ui/compute_logic.py
39
40
  src/moleditpy_linux/ui/constrained_optimization_dialog.py
@@ -1,6 +1,6 @@
1
1
  numpy
2
2
  pyvista<0.49
3
- pyvistaqt<0.12
3
+ pyvistaqt<0.13
4
4
  rdkit<2026.4
5
5
 
6
6
  [:python_version != "3.9"]
@@ -43,6 +43,34 @@ from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
43
43
  if TYPE_CHECKING:
44
44
  from .bond_item import BondItem
45
45
 
46
+ SUBSCRIPT_MAP = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
47
+
48
+ # Every subscript digit, so one warm-up covers any implicit-H count.
49
+ _WARM_UP_TEXT = "CH" + "0123456789".translate(SUBSCRIPT_MAP)
50
+
51
+
52
+ def warm_font_cache(font: Optional[QFont] = None) -> None:
53
+ """Pay Qt's one-off font costs up front instead of on the first heteroatom.
54
+
55
+ Two process-wide costs hide in QFontMetricsF: the first text shaping in a
56
+ family (~10-120 ms, worse on a cold Windows font cache) and the first
57
+ lookup of a subscript digit, which Arial lacks — Qt answers that by walking
58
+ the whole system font database (~200-330 ms, measured).
59
+
60
+ Drawing a carbon skeleton pays neither: a bonded neutral carbon is
61
+ invisible, so it renders no glyph at all. The first O or N a user places
62
+ gets "OH₂" and pays both at once, mid-click, as a visible freeze.
63
+
64
+ Call once on the GUI thread after the window is up. QFontMetricsF is not
65
+ usable off the GUI thread, so this cannot move to a worker.
66
+ """
67
+ try:
68
+ QFontMetricsF(font or QFont(FONT_FAMILY, 20, FONT_WEIGHT_BOLD)).boundingRect(
69
+ _WARM_UP_TEXT
70
+ )
71
+ except (RuntimeError, TypeError, ValueError):
72
+ logging.debug("Font cache warm-up skipped", exc_info=True)
73
+
46
74
 
47
75
  class AtomItem(QGraphicsItem):
48
76
  """2D scene item representing a single atom in the molecule editor."""
@@ -138,8 +166,7 @@ class AtomItem(QGraphicsItem):
138
166
  if not is_skeletal_carbon:
139
167
  hydrogen_part = "H"
140
168
  if self.implicit_h_count > 1:
141
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
142
- hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
169
+ hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
143
170
 
144
171
  flip_text = False
145
172
  if hydrogen_part and self.bonds:
@@ -261,8 +288,7 @@ class AtomItem(QGraphicsItem):
261
288
  if not is_skeletal_carbon:
262
289
  hydrogen_part = "H"
263
290
  if self.implicit_h_count > 1:
264
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
265
- hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
291
+ hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
266
292
 
267
293
  flip_text = False
268
294
  if hydrogen_part and self.bonds:
@@ -361,9 +387,8 @@ class AtomItem(QGraphicsItem):
361
387
  if not is_skeletal_carbon:
362
388
  hydrogen_part = "H"
363
389
  if self.implicit_h_count > 1:
364
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
365
390
  hydrogen_part += str(self.implicit_h_count).translate(
366
- subscript_map
391
+ SUBSCRIPT_MAP
367
392
  )
368
393
 
369
394
  # --- Determine if the text should be flipped ---
@@ -0,0 +1,178 @@
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 __future__ import annotations
14
+
15
+ import math
16
+ from typing import Any, List, Optional, Tuple
17
+
18
+ from PyQt6.QtCore import QPointF
19
+
20
+ from .atom_item import AtomItem
21
+ from ..utils.constants import DEFAULT_BOND_LENGTH
22
+
23
+ # Half of the 120 deg zigzag angle, measured from the chain axis.
24
+ CHAIN_HALF_ANGLE_DEG = 30.0
25
+ CHAIN_ANGLE_SNAP_DEG = 15.0
26
+ MAX_CHAIN_LENGTH = 100
27
+ # Reject an end snap that would collapse the tail bond onto its neighbour.
28
+ MIN_TAIL_BOND_RATIO = 0.4
29
+
30
+
31
+ class ChainMixin:
32
+ """Drag-to-draw tool that grows a fixed-bond-length zigzag alkyl chain."""
33
+
34
+ add_molecule_fragment: Any
35
+ current_atom_symbol: Any
36
+ find_atom_near: Any
37
+ get_setting: Any
38
+ template_preview: Any
39
+
40
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
41
+ self.chain_anchor: Optional[QPointF] = None
42
+ self.chain_start_atom: Optional[AtomItem] = None
43
+ self.chain_end_atom: Optional[AtomItem] = None
44
+ self.chain_points: List[QPointF] = []
45
+ self.chain_active: bool = False
46
+ super().__init__(*args, **kwargs)
47
+
48
+ def begin_chain(self, pos: QPointF, start_atom: Optional[AtomItem] = None) -> None:
49
+ """Anchor a new chain drag at ``pos`` (or at ``start_atom`` if given)."""
50
+ self.chain_start_atom = start_atom
51
+ self.chain_end_atom = None
52
+ self.chain_anchor = start_atom.pos() if start_atom is not None else QPointF(pos)
53
+ self.chain_points = []
54
+ self.chain_active = True
55
+
56
+ def chain_geometry(
57
+ self, cursor: QPointF
58
+ ) -> Tuple[List[QPointF], Optional[AtomItem]]:
59
+ """Zigzag for this cursor, with the last vertex pulled onto a nearby atom.
60
+
61
+ Every other bond keeps the fixed length, so the tail bond absorbs the
62
+ whole difference and may end up longer or shorter. Both ends of the drag
63
+ use the shared 2D bond snapping distance, as bond drawing does.
64
+ """
65
+ if self.chain_anchor is None:
66
+ return [], None
67
+
68
+ points = self.calculate_chain_points(self.chain_anchor, cursor)
69
+ if len(points) < 2:
70
+ return points, None
71
+
72
+ snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
73
+ target = self.find_atom_near(cursor, tol=snap_dist)
74
+ if target is None or target is self.chain_start_atom:
75
+ return points, None
76
+
77
+ end = target.pos()
78
+ prev = points[-2]
79
+ tail = math.hypot(end.x() - prev.x(), end.y() - prev.y())
80
+ if tail < MIN_TAIL_BOND_RATIO * DEFAULT_BOND_LENGTH:
81
+ return points, None
82
+
83
+ points[-1] = end
84
+ return points, target
85
+
86
+ def calculate_chain_points(self, anchor: QPointF, cursor: QPointF) -> List[QPointF]:
87
+ """Return the zigzag vertices from ``anchor`` toward ``cursor``.
88
+
89
+ The chain axis is snapped to 15 deg steps, every bond keeps the standard
90
+ 2D bond length, and the number of bonds follows the drag distance
91
+ projected onto the axis.
92
+ """
93
+ vx = cursor.x() - anchor.x()
94
+ vy = cursor.y() - anchor.y()
95
+
96
+ snap = math.radians(CHAIN_ANGLE_SNAP_DEG)
97
+ axis_angle = round(math.atan2(vy, vx) / snap) * snap
98
+ ax, ay = math.cos(axis_angle), math.sin(axis_angle)
99
+
100
+ half = math.radians(CHAIN_HALF_ANGLE_DEG)
101
+ step = DEFAULT_BOND_LENGTH * math.cos(half)
102
+ offset = DEFAULT_BOND_LENGTH * math.sin(half)
103
+
104
+ count = int(round((vx * ax + vy * ay) / step))
105
+ if count < 1:
106
+ return []
107
+ count = min(count, MAX_CHAIN_LENGTH)
108
+
109
+ # Zigzag bulges toward the side of the axis the cursor drifted to.
110
+ side = -1.0 if (ax * vy - ay * vx) < 0 else 1.0
111
+ px, py = -ay * offset * side, ax * offset * side
112
+
113
+ points = [QPointF(anchor)]
114
+ for k in range(1, count + 1):
115
+ wobble = 1.0 if k % 2 else 0.0
116
+ points.append(
117
+ QPointF(
118
+ anchor.x() + ax * step * k + px * wobble,
119
+ anchor.y() + ay * step * k + py * wobble,
120
+ )
121
+ )
122
+ return points
123
+
124
+ def update_chain_preview(self, pos: QPointF) -> None:
125
+ """Refresh the ghost chain and the repeat-count label near the cursor."""
126
+ if not self.chain_active or self.chain_anchor is None:
127
+ return
128
+
129
+ self.chain_points, self.chain_end_atom = self.chain_geometry(pos)
130
+ if len(self.chain_points) < 2:
131
+ self.template_preview.hide()
132
+ return
133
+
134
+ self.template_preview.set_chain_geometry(
135
+ self.chain_points,
136
+ f"n = {self.new_atom_count()}",
137
+ QPointF(pos.x() + 16, pos.y() - 12),
138
+ )
139
+ self.template_preview.show()
140
+
141
+ def new_atom_count(self) -> int:
142
+ """Atoms the previewed chain would add; reused end atoms do not count."""
143
+ if not self.chain_points:
144
+ return 0
145
+ reused = sum(
146
+ 1
147
+ for atom in (self.chain_start_atom, self.chain_end_atom)
148
+ if atom is not None
149
+ )
150
+ return len(self.chain_points) - reused
151
+
152
+ def clear_chain_preview(self) -> None:
153
+ """Hide the ghost chain and drop the drag state."""
154
+ self.chain_anchor = None
155
+ self.chain_start_atom = None
156
+ self.chain_end_atom = None
157
+ self.chain_points = []
158
+ self.chain_active = False
159
+ self.template_preview.hide()
160
+
161
+ def commit_chain(self, pos: QPointF) -> bool:
162
+ """Materialize the previewed chain as atoms and bonds. True if anything was added."""
163
+ if self.chain_anchor is None:
164
+ return False
165
+
166
+ points, end_atom = self.chain_geometry(pos)
167
+ if len(points) < 2:
168
+ return False
169
+
170
+ bonds_info = [(i, i + 1, 1) for i in range(len(points) - 1)]
171
+ # Naming both reused atoms fuses them even with template fusing turned off.
172
+ existing = [
173
+ atom for atom in (self.chain_start_atom, end_atom) if atom is not None
174
+ ]
175
+ self.add_molecule_fragment(
176
+ points, bonds_info, existing_items=existing, symbol=self.current_atom_symbol
177
+ )
178
+ return True
@@ -20,9 +20,6 @@ import os
20
20
  import sys
21
21
  from typing import Any, Dict, Optional
22
22
 
23
- # RDKit imports (explicit to satisfy flake8 and used features)
24
- from rdkit import Chem
25
-
26
23
  # PyQt6 Modules
27
24
  from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, QUrl
28
25
  from PyQt6.QtGui import (
@@ -65,7 +62,8 @@ except ImportError:
65
62
  winreg = None # type: ignore[assignment]
66
63
 
67
64
 
68
- from ..utils.constants import DEFAULT_CPK_COLORS, NUM_DASHES, VERSION
65
+ from ..utils.constants import DEFAULT_CPK_COLORS, FONT_FAMILY, NUM_DASHES, VERSION
66
+ from .atom_item import warm_font_cache
69
67
  from .custom_qt_interactor import CustomQtInteractor
70
68
  from ..core.molecular_data import MolecularData
71
69
  from .molecule_scene import MoleculeScene
@@ -74,6 +72,10 @@ from .settings_dialog import SettingsDialog
74
72
  from .zoomable_view import ZoomableView
75
73
 
76
74
 
75
+ # Distinct from None, which is a real answer meaning "OS preference unknown".
76
+ _UNQUERIED = object()
77
+
78
+
77
79
  class MainInitManager:
78
80
  """Feature class separated from main_window.py"""
79
81
 
@@ -82,6 +84,7 @@ class MainInitManager:
82
84
  ) -> None:
83
85
  self.host = host
84
86
  # Explicit declarations for Mypy
87
+ self._os_dark_pref: Any = _UNQUERIED
85
88
  self.scene: Any = None # MoleculeScene, created during init
86
89
  self.data: Optional[MolecularData] = None
87
90
  self.formula_label: Optional[QLabel] = None
@@ -179,18 +182,6 @@ class MainInitManager:
179
182
  # Install event filter to capture window close events (handled in UIManager)
180
183
  self.host.installEventFilter(self.host.ui_manager)
181
184
 
182
- # RDKit Warm-up (initial execution cost)
183
- try:
184
- # Create a molecule with a variety of common atoms to ensure
185
- # the valence/H-count machinery is fully initialized.
186
- warmup_smiles = "OC(N)C(S)P"
187
- warmup_mol = Chem.MolFromSmiles(warmup_smiles)
188
- if warmup_mol:
189
- for atom in warmup_mol.GetAtoms():
190
- atom.GetNumImplicitHs()
191
- except (AttributeError, RuntimeError, ValueError) as e:
192
- logging.warning(f"RDKit warm-up failed: {e}")
193
-
194
185
  self.host.state_manager.reset_undo_stack()
195
186
  self.scene.selectionChanged.connect( # type: ignore[attr-defined]
196
187
  self.host.edit_actions_manager.update_edit_menu_actions
@@ -206,6 +197,8 @@ class MainInitManager:
206
197
  QTimer.singleShot(0, lambda: self.load_command_line_file(initial_file))
207
198
 
208
199
  QTimer.singleShot(0, self.apply_initial_settings)
200
+ # After apply_initial_settings, so the user's own font family is warmed.
201
+ QTimer.singleShot(0, self._warm_font_cache)
209
202
  # Camera initialization flag (permits reset only during the first draw)
210
203
  self._camera_initialized = False
211
204
 
@@ -226,6 +219,19 @@ class MainInitManager:
226
219
  f"Suppressed exception: {e}"
227
220
  ) # Suppress non-critical UI/menu initialization errors
228
221
 
222
+ def _warm_font_cache(self) -> None:
223
+ """Resolve the atom-label font now, in the user's configured family."""
224
+ scene = getattr(self, "scene", None)
225
+ font = None
226
+ if scene is not None and hasattr(scene, "get_setting"):
227
+ family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
228
+ size = scene.get_setting("atom_font_size_2d", 20)
229
+ bold = scene.get_setting("atom_font_bold_2d", True)
230
+ font = QFont(
231
+ family, size, QFont.Weight.Bold if bold else QFont.Weight.Normal
232
+ )
233
+ warm_font_cache(font)
234
+
229
235
  def init_ui(self) -> None:
230
236
  """Set the window icon and initialize main layout, toolbars, and menu bar."""
231
237
  script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -867,6 +873,17 @@ class MainInitManager:
867
873
  self.tool_group.addAction(action) # type: ignore[union-attr]
868
874
  toolbar.addSeparator()
869
875
 
876
+ def _add_chain_action(self, toolbar: QToolBar) -> None:
877
+ """Add the drag-to-draw alkyl chain action."""
878
+ action = QAction(self.host)
879
+ action.setIcon(self._create_chain_icon())
880
+ action.setToolTip("Alkyl Chain — drag to draw a zigzag chain")
881
+ action.setCheckable(True)
882
+ action.triggered.connect(lambda checked: self.host.ui_manager.set_mode("chain"))
883
+ self.mode_actions["chain"] = action
884
+ toolbar.addAction(action)
885
+ self.tool_group.addAction(action) # type: ignore[union-attr]
886
+
870
887
  def _add_charge_radical_actions(self, toolbar: QToolBar) -> None:
871
888
  """Add charge and radical modification actions."""
872
889
  ops = [
@@ -904,6 +921,8 @@ class MainInitManager:
904
921
  toolbar_bottom.addAction(action)
905
922
  self.tool_group.addAction(action) # type: ignore[union-attr]
906
923
 
924
+ self._add_chain_action(toolbar_bottom)
925
+
907
926
  user_action = QAction("USER", self.host)
908
927
  user_action.setCheckable(True)
909
928
  user_action.setToolTip("Open User Templates Dialog")
@@ -1033,6 +1052,57 @@ class MainInitManager:
1033
1052
  painter.end()
1034
1053
  return QIcon(pixmap)
1035
1054
 
1055
+ def _create_chain_icon(self, size: int = 32) -> QIcon:
1056
+ """Generate the -(-)n- repeat-unit icon for the alkyl chain tool."""
1057
+ fg = self._get_icon_foreground_color()
1058
+ pixmap = QPixmap(size, size)
1059
+ pixmap.fill(Qt.GlobalColor.transparent)
1060
+ painter = QPainter(pixmap)
1061
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1062
+ painter.setPen(QPen(fg, max(1.8, size / 16.0)))
1063
+ painter.setBrush(Qt.BrushStyle.NoBrush)
1064
+
1065
+ cy = size * 0.375
1066
+ amp = size * 0.17
1067
+ # Backbone zigzag spans the full width so the repeat brackets cross it.
1068
+ zig = [
1069
+ QPointF(1 + i * (size - 2) / 2.0, cy + (amp if i % 2 == 0 else -amp))
1070
+ for i in range(3)
1071
+ ]
1072
+ for start, end in zip(zig, zig[1:]):
1073
+ painter.drawLine(start, end)
1074
+
1075
+ painter.setPen(QPen(fg, max(1.5, size / 20.0)))
1076
+ bracket_h = size * 0.55
1077
+ bracket_w = size * 0.19
1078
+ inset = size * 0.13
1079
+ painter.drawArc(
1080
+ QRectF(inset, cy - bracket_h / 2, bracket_w, bracket_h), 118 * 16, 124 * 16
1081
+ )
1082
+ painter.drawArc(
1083
+ QRectF(size - inset - bracket_w, cy - bracket_h / 2, bracket_w, bracket_h),
1084
+ -62 * 16,
1085
+ 124 * 16,
1086
+ )
1087
+
1088
+ font = painter.font()
1089
+ font.setPixelSize(max(8, int(size * 0.30)))
1090
+ font.setBold(True)
1091
+ painter.setFont(font)
1092
+ painter.drawText(
1093
+ QRectF(
1094
+ size - size * 0.26,
1095
+ cy + bracket_h / 2 - size * 0.06,
1096
+ size * 0.24,
1097
+ size * 0.36,
1098
+ ),
1099
+ Qt.AlignmentFlag.AlignCenter,
1100
+ "n",
1101
+ )
1102
+
1103
+ painter.end()
1104
+ return QIcon(pixmap)
1105
+
1036
1106
  def _create_template_icon(
1037
1107
  self, n: int, is_benzene: bool = False, size: int = 32
1038
1108
  ) -> QIcon:
@@ -1076,7 +1146,11 @@ class MainInitManager:
1076
1146
  return QColor(fg)
1077
1147
 
1078
1148
  with suppress_log(Exception):
1079
- os_pref = detect_system_dark_mode()
1149
+ # Cached: this forks `defaults`/`gsettings` on macOS and Linux, and
1150
+ # icon painting asks once per icon (15+ during startup).
1151
+ if self._os_dark_pref is _UNQUERIED:
1152
+ self._os_dark_pref = detect_system_dark_mode()
1153
+ os_pref = self._os_dark_pref
1080
1154
  if os_pref is not None:
1081
1155
  return QColor("#FFFFFF") if os_pref else QColor("#000000")
1082
1156
 
@@ -36,9 +36,12 @@ from .template_preview_item import TemplatePreviewItem
36
36
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
37
37
 
38
38
  from .molecular_scene_handler import TemplateMixin, KeyboardMixin, SceneQueryMixin
39
+ from .chain_mixin import ChainMixin
39
40
 
40
41
 
41
- class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScene):
42
+ class MoleculeScene(
43
+ ChainMixin, TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScene
44
+ ):
42
45
  """Central QGraphicsScene that owns all 2D atom and bond items."""
43
46
 
44
47
  def __init__(self, data: MolecularData, window: MainWindow) -> None:
@@ -433,6 +436,15 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
433
436
  event.accept()
434
437
  return # Complete right-click processing and prevent proceeding to left-click logic
435
438
 
439
+ if self.mode == "chain":
440
+ self.clearSelection()
441
+ snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
442
+ self.begin_chain(
443
+ self.press_pos, self.find_atom_near(self.press_pos, tol=snap_dist)
444
+ )
445
+ event.accept()
446
+ return
447
+
436
448
  if self.mode.startswith("template"):
437
449
  self.clearSelection() # In template mode, no selection is made; only the click position is recorded.
438
450
  return
@@ -487,6 +499,10 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
487
499
  if self.mode.startswith("template"):
488
500
  self.update_template_preview(event.scenePos())
489
501
 
502
+ if self.mode == "chain" and self.chain_active:
503
+ self.update_chain_preview(event.scenePos())
504
+ return
505
+
490
506
  if not self.mouse_moved_since_press and self.press_pos:
491
507
  if (
492
508
  event.scenePos() - self.press_pos
@@ -544,6 +560,20 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
544
560
 
545
561
  self.temp_line = None
546
562
 
563
+ if self.mode == "chain":
564
+ committed = False
565
+ if self.chain_active:
566
+ committed = self.commit_chain(end_pos)
567
+ self.clear_chain_preview()
568
+ self.start_atom = None
569
+ self.start_pos = None
570
+ self.press_pos = None
571
+ if committed:
572
+ self.data_changed_in_event = True
573
+ self.update_all_items()
574
+ self.window.edit_actions_manager.push_undo_state()
575
+ return
576
+
547
577
  if self.mode.startswith("template") and is_click:
548
578
  if self.template_context and self.template_context.get("points"):
549
579
  context = self.template_context
@@ -34,6 +34,10 @@ class TemplatePreviewItem(QGraphicsItem):
34
34
  self.user_template_bonds: List[Any] = []
35
35
  self.user_template_atoms: List[Any] = []
36
36
  self.is_user_template = False
37
+ self.chain_points: List[QPointF] = []
38
+ self.chain_label = ""
39
+ self.chain_label_pos = QPointF()
40
+ self.is_chain = False
37
41
 
38
42
  def set_geometry(self, points: list[QPointF], is_aromatic: bool = False) -> None:
39
43
  """Set polygon points for a standard ring template preview."""
@@ -41,6 +45,21 @@ class TemplatePreviewItem(QGraphicsItem):
41
45
  self.polygon = QPolygonF(points)
42
46
  self.is_aromatic = is_aromatic
43
47
  self.is_user_template = False
48
+ self.is_chain = False
49
+ self.update()
50
+
51
+ def set_chain_geometry(
52
+ self, points: list[QPointF], label: str, label_pos: QPointF
53
+ ) -> None:
54
+ """Set the zigzag vertices and the repeat-count label for a chain preview."""
55
+ self.prepareGeometryChange()
56
+ self.chain_points = list(points)
57
+ self.chain_label = label
58
+ self.chain_label_pos = QPointF(label_pos)
59
+ self.is_chain = True
60
+ self.is_user_template = False
61
+ self.is_aromatic = False
62
+ self.polygon = QPolygonF()
44
63
  self.update()
45
64
 
46
65
  def set_user_template_geometry(
@@ -55,12 +74,22 @@ class TemplatePreviewItem(QGraphicsItem):
55
74
  self.user_template_bonds = bonds_info
56
75
  self.user_template_atoms = atoms_data
57
76
  self.is_user_template = True
77
+ self.is_chain = False
58
78
  self.is_aromatic = False
59
79
  self.polygon = QPolygonF()
60
80
  self.update()
61
81
 
62
82
  def boundingRect(self) -> QRectF:
63
83
  """Return the bounding rect encompassing the preview geometry."""
84
+ if self.is_chain and self.chain_points:
85
+ xs = [p.x() for p in self.chain_points] + [self.chain_label_pos.x()]
86
+ ys = [p.y() for p in self.chain_points] + [self.chain_label_pos.y()]
87
+ return QRectF(
88
+ min(xs) - 10,
89
+ min(ys) - 20,
90
+ max(xs) - min(xs) + 90,
91
+ max(ys) - min(ys) + 40,
92
+ )
64
93
  if self.is_user_template and self.user_template_points:
65
94
  # Calculate bounding rect for user template
66
95
  min_x = min(p.x() for p in self.user_template_points)
@@ -81,11 +110,43 @@ class TemplatePreviewItem(QGraphicsItem):
81
110
  """Dispatch to the regular or user-template painter."""
82
111
  if painter is None:
83
112
  return
84
- if self.is_user_template:
113
+ if self.is_chain:
114
+ self.paint_chain(painter)
115
+ elif self.is_user_template:
85
116
  self.paint_user_template(painter)
86
117
  else:
87
118
  self.paint_regular_template(painter)
88
119
 
120
+ def paint_chain(self, painter: QPainter) -> None:
121
+ """Paint the ghost zigzag chain plus the repeat count next to the cursor."""
122
+ if len(self.chain_points) < 2:
123
+ return
124
+
125
+ painter.setPen(QPen(QColor(80, 80, 80, 180), 2.5))
126
+ painter.setBrush(Qt.BrushStyle.NoBrush)
127
+ for i in range(len(self.chain_points) - 1):
128
+ painter.drawLine(QLineF(self.chain_points[i], self.chain_points[i + 1]))
129
+
130
+ dot = QColor(80, 80, 80, 140)
131
+ painter.setBrush(QBrush(dot))
132
+ painter.setPen(QPen(dot, 1))
133
+ for p in self.chain_points[1:]:
134
+ painter.drawEllipse(p, 2.5, 2.5)
135
+
136
+ if not self.chain_label:
137
+ return
138
+ font = QFont("Arial", 11, QFont.Weight.Bold)
139
+ painter.setFont(font)
140
+ metrics = painter.fontMetrics()
141
+ rect = QRectF(metrics.boundingRect(self.chain_label))
142
+ rect.moveTopLeft(self.chain_label_pos)
143
+ rect = rect.adjusted(-5, -3, 5, 3)
144
+ painter.setPen(QPen(QColor(80, 80, 80, 180), 1))
145
+ painter.setBrush(QBrush(QColor(255, 255, 255, 225)))
146
+ painter.drawRoundedRect(rect, 4, 4)
147
+ painter.setPen(QPen(QColor(40, 40, 40)))
148
+ painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.chain_label)
149
+
89
150
  def paint_regular_template(self, painter: QPainter) -> None:
90
151
  """Paint a standard ring or aromatic template as a polygon outline."""
91
152
  painter.setPen(self.pen)
@@ -62,6 +62,10 @@ class UIManager(QObject):
62
62
 
63
63
  # Trigger immediate scene refresh to show/update template previews
64
64
  self.host.init_manager.scene.refresh_mode_state()
65
+ # A shortcut key can switch modes mid-drag; drop the abandoned chain anchor.
66
+ if prev_mode == "chain" and mode_str != "chain":
67
+ self.host.init_manager.scene.clear_chain_preview()
68
+
65
69
  # Clear ghost when leaving template mode
66
70
  if (
67
71
  prev_mode
@@ -75,7 +79,7 @@ class UIManager(QObject):
75
79
  # Set cursor shape
76
80
  if mode_str == "select":
77
81
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.ArrowCursor)
78
- elif mode_str.startswith(("atom", "bond", "template")):
82
+ elif mode_str.startswith(("atom", "bond", "template", "chain")):
79
83
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
80
84
  elif mode_str.startswith(("charge", "radical")):
81
85
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
@@ -101,6 +105,14 @@ class UIManager(QObject):
101
105
  )
102
106
  self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
103
107
  self.host.init_manager.view_2d.setMouseTracking(True)
108
+ elif mode_str == "chain":
109
+ self.host.set_scene_atom_symbol("C")
110
+ self.host.set_scene_bond_properties(1, 0)
111
+ self.host.update_status_message(
112
+ "Mode: Alkyl Chain (Drag to set the chain length)"
113
+ )
114
+ self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
115
+ self.host.init_manager.view_2d.setMouseTracking(True)
104
116
  elif mode_str.startswith("template"):
105
117
  if mode_str.startswith("template_user"):
106
118
  # User template mode
@@ -113,7 +113,7 @@ class ZoomableView(QGraphicsView):
113
113
  current_mode = self.scene().mode if self.scene() else "select" # type: ignore[union-attr]
114
114
  if current_mode == "select":
115
115
  self.setCursor(Qt.CursorShape.ArrowCursor)
116
- elif current_mode.startswith(("atom", "bond", "template")):
116
+ elif current_mode.startswith(("atom", "bond", "template", "chain")):
117
117
  self.setCursor(Qt.CursorShape.CrossCursor)
118
118
  elif current_mode.startswith(("charge", "radical")):
119
119
  self.setCursor(Qt.CursorShape.CrossCursor)
@@ -21,6 +21,9 @@ try:
21
21
  except ImportError:
22
22
  winreg = None # type: ignore[assignment]
23
23
 
24
+ # A wedged `defaults`/`gsettings` must not take the app's startup with it.
25
+ _THEME_QUERY_TIMEOUT_S = 5.0
26
+
24
27
 
25
28
  def detect_system_dark_mode() -> Optional[bool]:
26
29
  """Return True if the OS prefers dark app theme, False if light, or None if unknown."""
@@ -33,8 +36,18 @@ def detect_system_dark_mode() -> Optional[bool]:
33
36
 
34
37
 
35
38
  def detect_system_theme() -> Optional[str]:
36
- """Return the OS's preferred theme setting as 'dark', 'light', or None."""
37
- with suppress_log(AttributeError, RuntimeError, OSError, FileNotFoundError):
39
+ """Return the OS's preferred theme setting as 'dark', 'light', or None.
40
+
41
+ Forks a helper process on macOS and Linux, so callers that ask repeatedly
42
+ should hold on to the answer rather than re-querying.
43
+ """
44
+ with suppress_log(
45
+ AttributeError,
46
+ RuntimeError,
47
+ OSError,
48
+ FileNotFoundError,
49
+ subprocess.SubprocessError,
50
+ ):
38
51
  # Windows
39
52
  if platform.system() == "Windows" and winreg is not None:
40
53
  with winreg.OpenKey(
@@ -50,6 +63,7 @@ def detect_system_theme() -> Optional[str]:
50
63
  ["defaults", "read", "-g", "AppleInterfaceStyle"],
51
64
  capture_output=True,
52
65
  text=True,
66
+ timeout=_THEME_QUERY_TIMEOUT_S,
53
67
  )
54
68
  if p.returncode == 0 and "dark" in p.stdout.strip().lower():
55
69
  return "dark"
@@ -61,6 +75,7 @@ def detect_system_theme() -> Optional[str]:
61
75
  ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
62
76
  capture_output=True,
63
77
  text=True,
78
+ timeout=_THEME_QUERY_TIMEOUT_S,
64
79
  )
65
80
  if p.returncode == 0:
66
81
  out = p.stdout.strip().strip("'\n ")
@@ -73,6 +88,7 @@ def detect_system_theme() -> Optional[str]:
73
88
  ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
74
89
  capture_output=True,
75
90
  text=True,
91
+ timeout=_THEME_QUERY_TIMEOUT_S,
76
92
  )
77
93
  if p.returncode == 0 and "-dark" in p.stdout.lower():
78
94
  return "dark"
File without changes