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.
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/PKG-INFO +3 -2
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/README.md +1 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/pyproject.toml +2 -2
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/PKG-INFO +3 -2
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/requires.txt +1 -1
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/atom_item.py +31 -6
- moleditpy_linux-4.6.0/src/moleditpy_linux/ui/chain_mixin.py +178 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/main_window_init.py +91 -17
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/molecule_scene.py +31 -1
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_item.py +62 -1
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/ui_manager.py +13 -1
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/zoomable_view.py +1 -1
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/system_utils.py +18 -2
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/LICENSE +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/setup.cfg +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/__main__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.icns +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.ico +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/assets/icon.png +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/main.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/app_state.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/main_window.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/__init__.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/constants.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
- {moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_item.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/MoleditPy_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_interface.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/plugins/plugin_manager_window.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/align_plane_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/base_picking_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/bond_length_dialog.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/calculation_worker.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/color_settings_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_interactor_style.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/custom_qt_interactor.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/edit_actions_logic.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/geometry_base_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/molecular_scene_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/periodic_table_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/plugin_menu_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/template_preview_view.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/translation_dialog.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/ui/user_template_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/default_settings.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.1 → moleditpy_linux-4.6.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py
RENAMED
|
File without changes
|
|
File without changes
|