MoleditPy-linux 4.8.0__tar.gz → 4.8.2__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 (84) hide show
  1. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/PKG-INFO +4 -4
  2. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/README.md +3 -3
  3. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/pyproject.toml +1 -1
  4. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/PKG-INFO +4 -4
  5. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/plugins/plugin_interface.py +12 -2
  6. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/plugins/plugin_manager.py +14 -2
  7. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/calculation_worker.py +87 -10
  8. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/edit_actions_logic.py +4 -2
  9. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/molecular_scene_handler.py +121 -18
  10. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/plugin_menu_manager.py +231 -60
  11. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/template_preview_item.py +17 -1
  12. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/LICENSE +0 -0
  13. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/setup.cfg +0 -0
  14. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  15. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  16. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  17. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  18. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  19. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/__init__.py +0 -0
  20. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/__main__.py +0 -0
  21. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  22. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/assets/icon.icns +0 -0
  23. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/assets/icon.ico +0 -0
  24. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/assets/icon.png +0 -0
  25. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/core/__init__.py +0 -0
  26. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  27. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/core/molecular_data.py +0 -0
  28. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/main.py +0 -0
  29. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/plugins/__init__.py +0 -0
  30. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  31. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/__init__.py +0 -0
  32. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  33. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  34. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  35. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  36. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  37. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/app_state.py +0 -0
  38. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/atom_item.py +0 -0
  39. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  40. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  41. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/bond_item.py +0 -0
  42. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  43. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  44. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  45. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  46. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  47. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  48. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  49. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  50. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  51. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  52. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  53. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/export_logic.py +0 -0
  54. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  55. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/io_logic.py +0 -0
  56. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/main_window.py +0 -0
  57. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  58. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  59. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  60. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  61. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  62. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  63. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  64. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  65. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  66. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  67. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  68. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  69. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  70. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  71. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/string_importers.py +0 -0
  72. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  73. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  74. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  75. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  76. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  77. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  78. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/__init__.py +0 -0
  79. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/constants.py +0 -0
  80. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/default_settings.py +0 -0
  81. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  82. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  83. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  84. {moleditpy_linux-4.8.0 → moleditpy_linux-4.8.2}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.8.0
3
+ Version: 4.8.2
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
@@ -709,9 +709,9 @@ Dynamic: license-file
709
709
  [![Python Versions](https://img.shields.io/pypi/pyversions/MoleditPy.svg)](https://pypi.org/project/MoleditPy/)
710
710
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
711
711
  [![Build Status](https://github.com/HiroYokoyama/python_molecular_editor/actions/workflows/tests.yml/badge.svg)](https://github.com/HiroYokoyama/python_molecular_editor/actions)
712
- ![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)
713
- ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
714
- ![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)
712
+ [![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/coverage_report.md)
713
+ [![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/MANUAL_CHECKLIST.md)
714
+ [![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/pylint-score.txt)
715
715
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/moleditpy)
716
716
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=monthly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=monthly+downloads)](https://pepy.tech/projects/moleditpy)
717
717
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
@@ -6,9 +6,9 @@
6
6
  [![Python Versions](https://img.shields.io/pypi/pyversions/MoleditPy.svg)](https://pypi.org/project/MoleditPy/)
7
7
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
8
8
  [![Build Status](https://github.com/HiroYokoyama/python_molecular_editor/actions/workflows/tests.yml/badge.svg)](https://github.com/HiroYokoyama/python_molecular_editor/actions)
9
- ![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)
10
- ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
11
- ![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)
9
+ [![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/coverage_report.md)
10
+ [![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/MANUAL_CHECKLIST.md)
11
+ [![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/pylint-score.txt)
12
12
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/moleditpy)
13
13
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=monthly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=monthly+downloads)](https://pepy.tech/projects/moleditpy)
14
14
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.8.0"
8
+ version = "4.8.2"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.8.0
3
+ Version: 4.8.2
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
@@ -709,9 +709,9 @@ Dynamic: license-file
709
709
  [![Python Versions](https://img.shields.io/pypi/pyversions/MoleditPy.svg)](https://pypi.org/project/MoleditPy/)
710
710
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
711
711
  [![Build Status](https://github.com/HiroYokoyama/python_molecular_editor/actions/workflows/tests.yml/badge.svg)](https://github.com/HiroYokoyama/python_molecular_editor/actions)
712
- ![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)
713
- ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
714
- ![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)
712
+ [![Overall Coverage](https://img.shields.io/badge/coverage->80%25-green)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/coverage_report.md)
713
+ [![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/MANUAL_CHECKLIST.md)
714
+ [![Pylint Score](https://img.shields.io/badge/pylint->9%2F10-brightgreen)](https://github.com/HiroYokoyama/python_molecular_editor/blob/main/tests/pylint-score.txt)
715
715
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/moleditpy)
716
716
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=monthly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=monthly+downloads)](https://pepy.tech/projects/moleditpy)
717
717
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
@@ -31,6 +31,7 @@ class PluginContext:
31
31
  text: Optional[str] = None,
32
32
  icon: Optional[str] = None,
33
33
  shortcut: Optional[str] = None,
34
+ pin: Optional[str] = None,
34
35
  ) -> None:
35
36
  """
36
37
  Register a menu action.
@@ -41,9 +42,16 @@ class PluginContext:
41
42
  text: Label for the action (defaults to last part of path if None).
42
43
  icon: Path to icon or icon name (optional).
43
44
  shortcut: Keyboard shortcut (optional).
45
+ pin: Placement request. ``"header"`` puts a direct "Plugin/<entry>"
46
+ registration next to "Plugin Manager..." above the first
47
+ divider, for plugins that manage the plugin system itself.
48
+ Anything else is ignored, so unknown values stay forward
49
+ compatible. Added in 4.8.1 — passing it to an older MoleditPy
50
+ raises TypeError, so guard the call if your plugin supports
51
+ both (see PLUGIN_DEVELOPMENT_MANUAL_V4.md).
44
52
  """
45
53
  self._manager.register_menu_action(
46
- self._plugin_name, path, callback, text, icon, shortcut
54
+ self._plugin_name, path, callback, text, icon, shortcut, pin
47
55
  )
48
56
 
49
57
  def register_menu_action(
@@ -72,6 +80,7 @@ class PluginContext:
72
80
  text: Optional[str] = None,
73
81
  icon: Optional[str] = None,
74
82
  shortcut: Optional[str] = None,
83
+ pin: Optional[str] = None,
75
84
  ) -> None:
76
85
  """
77
86
  Register an action nested inside the Plugin menu.
@@ -87,9 +96,10 @@ class PluginContext:
87
96
  text: Label override (defaults to last part of path).
88
97
  icon: Path to icon (optional).
89
98
  shortcut: Keyboard shortcut (optional).
99
+ pin: Placement request; see add_menu_action.
90
100
  """
91
101
  full_path = f"Plugin/{path.lstrip('/')}"
92
- self.add_menu_action(full_path, callback, text, icon, shortcut)
102
+ self.add_menu_action(full_path, callback, text, icon, shortcut, pin)
93
103
 
94
104
  def add_toolbar_action(
95
105
  self,
@@ -358,7 +358,13 @@ class PluginManager:
358
358
  plugin_desc = getattr(
359
359
  module, "PLUGIN_DESCRIPTION", getattr(module, "__doc__", "")
360
360
  )
361
- plugin_category = getattr(module, "PLUGIN_CATEGORY", category)
361
+ # The folder the user filed the plugin under wins: that
362
+ # placement is a deliberate, user-controlled ordering. A
363
+ # plugin's own PLUGIN_CATEGORY only applies when the file sits
364
+ # at the plugins root, where there is no folder to honour.
365
+ plugin_category = category or str(
366
+ getattr(module, "PLUGIN_CATEGORY", "") or ""
367
+ )
362
368
 
363
369
  # Additional cleanup for docstring (strip whitespace)
364
370
  if plugin_desc is None:
@@ -445,8 +451,13 @@ class PluginManager:
445
451
  text: str,
446
452
  icon: str,
447
453
  shortcut: str,
454
+ pin: Optional[str] = None,
448
455
  ) -> None:
449
- """Register a plugin menu action with its path, callback, and display metadata."""
456
+ """Register a plugin menu action with its path, callback, and display metadata.
457
+
458
+ ``pin`` is optional so a V3-era caller reaching this method directly
459
+ keeps working.
460
+ """
450
461
  self.menu_actions.append(
451
462
  {
452
463
  "plugin": plugin_name,
@@ -455,6 +466,7 @@ class PluginManager:
455
466
  "text": text,
456
467
  "icon": icon,
457
468
  "shortcut": shortcut,
469
+ "pin": pin,
458
470
  }
459
471
  )
460
472
 
@@ -26,6 +26,8 @@ from rdkit import Chem
26
26
  from rdkit.Chem import AllChem, rdGeometry
27
27
  from rdkit.DistanceGeometry import DoTriangleSmoothing
28
28
 
29
+ from ..utils.constants import DEFAULT_BOND_LENGTH_ANGSTROM
30
+
29
31
  from .. import OBABEL_AVAILABLE
30
32
 
31
33
  # Only import pybel on demand
@@ -367,6 +369,50 @@ def _apply_explicit_stereo(
367
369
  bond.SetStereo(stereo_type)
368
370
 
369
371
 
372
+ # Typical X-H distances used when placing hydrogens the 2D sketch never had.
373
+ _DIRECT_XH_LENGTHS = {6: 1.09, 7: 1.01, 8: 0.97, 15: 1.42, 16: 1.34}
374
+ _DIRECT_XH_DEFAULT = 1.10
375
+ # Out-of-plane tilt applied to added H and to wedge/hash bonds.
376
+ _DIRECT_H_TILT = math.radians(20.0)
377
+ _DIRECT_WEDGE_TILT = math.radians(35.0)
378
+
379
+
380
+ def _rescale_2d_layout(
381
+ coords: list, mol: Chem.Mol, _safe_status: Callable[[str], None]
382
+ ) -> list:
383
+ """Scale parsed 2D coordinates so the median bond is a chemically sane length."""
384
+ lengths = []
385
+ for bond in mol.GetBonds():
386
+ i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
387
+ if i < len(coords) and j < len(coords):
388
+ d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
389
+ if d > 1e-6:
390
+ lengths.append(d)
391
+ if not lengths:
392
+ return coords
393
+
394
+ median = float(np.median(lengths))
395
+ if median < 1e-6 or abs(median - DEFAULT_BOND_LENGTH_ANGSTROM) < 0.05:
396
+ return coords
397
+
398
+ scale = DEFAULT_BOND_LENGTH_ANGSTROM / median
399
+ _safe_status(f"Rescaling 2D layout (x{scale:.3f})...")
400
+ return [(x * scale, y * scale, z * scale) for x, y, z in coords]
401
+
402
+
403
+ def _center_conformer(conf: Chem.Conformer) -> None:
404
+ """Move the conformer's centroid to the origin."""
405
+ n = conf.GetNumAtoms()
406
+ if n == 0:
407
+ return
408
+ pos = np.array(
409
+ [[p.x, p.y, p.z] for p in (conf.GetAtomPosition(i) for i in range(n))],
410
+ dtype=float,
411
+ )
412
+ for i, (x, y, z) in enumerate(pos - pos.mean(axis=0)):
413
+ conf.SetAtomPosition(i, rdGeometry.Point3D(float(x), float(y), float(z)))
414
+
415
+
370
416
  def _perform_direct_conversion(
371
417
  mol_block: str,
372
418
  mol: Chem.Mol,
@@ -446,6 +492,8 @@ def _perform_direct_conversion(
446
492
  if not parsed_coords:
447
493
  raise ValueError("Failed to parse coordinates for direct conversion.")
448
494
 
495
+ parsed_coords = _rescale_2d_layout(parsed_coords, mol, _safe_status)
496
+
449
497
  num_existing = len(parsed_coords)
450
498
  conf = Chem.Conformer(mol.GetNumAtoms())
451
499
  for i in range(mol.GetNumAtoms()):
@@ -480,28 +528,45 @@ def _perform_direct_conversion(
480
528
  vecs[0][0],
481
529
  math.hypot(-vecs[0][1], vecs[0][0]),
482
530
  )
483
- angle = sum(
531
+ n_placed = sum(
484
532
  1
485
533
  for n in mol.GetAtomWithIdx(parent_idx).GetNeighbors()
486
534
  if n.GetIdx() < i and n.GetAtomicNum() == 1
487
- ) * (math.pi / 6.0)
535
+ )
536
+ angle = n_placed * (math.pi / 6.0)
488
537
  rx, ry = (
489
538
  (fx / fn) * math.cos(angle) - (fy / fn) * math.sin(angle),
490
539
  (fx / fn) * math.sin(angle) + (fy / fn) * math.cos(angle),
491
540
  )
541
+ bond_len = _DIRECT_XH_LENGTHS.get(
542
+ mol.GetAtomWithIdx(parent_idx).GetAtomicNum(),
543
+ _DIRECT_XH_DEFAULT,
544
+ )
545
+ in_plane = bond_len * math.cos(_DIRECT_H_TILT)
546
+ # Alternate the pucker so a CH2's two hydrogens straddle
547
+ # the plane instead of stacking on the same side.
548
+ dz = bond_len * math.sin(_DIRECT_H_TILT)
549
+ if n_placed % 2:
550
+ dz = -dz
492
551
  conf.SetAtomPosition(
493
552
  i,
494
553
  rdGeometry.Point3D(
495
- float(parent_pos.x) + rx, float(parent_pos.y) + ry, 0.3
554
+ float(parent_pos.x) + rx * in_plane,
555
+ float(parent_pos.y) + ry * in_plane,
556
+ float(parent_pos.z) + dz,
496
557
  ),
497
558
  )
498
559
  else:
560
+ bond_len = _DIRECT_XH_LENGTHS.get(
561
+ mol.GetAtomWithIdx(parent_idx).GetAtomicNum(),
562
+ _DIRECT_XH_DEFAULT,
563
+ )
499
564
  conf.SetAtomPosition(
500
565
  i,
501
566
  rdGeometry.Point3D(
502
- float(parent_pos.x) + 0.5,
503
- float(parent_pos.y) + 0.5,
504
- 0.3,
567
+ float(parent_pos.x) + bond_len,
568
+ float(parent_pos.y),
569
+ float(parent_pos.z),
505
570
  ),
506
571
  )
507
572
  else:
@@ -511,16 +576,28 @@ def _perform_direct_conversion(
511
576
  with suppress_log(AttributeError, RuntimeError, TypeError, IndexError):
512
577
  for b, e, flag in stereo_dirs:
513
578
  if b < num_existing and e < num_existing:
514
- pos = conf.GetAtomPosition(e)
579
+ start, pos = conf.GetAtomPosition(b), conf.GetAtomPosition(e)
580
+ vx, vy = float(pos.x) - float(start.x), float(pos.y) - float(start.y)
581
+ length = math.hypot(vx, vy)
582
+ if length < 1e-6:
583
+ continue
584
+ # Rotate the bond out of plane so it keeps its length; adding a
585
+ # flat +/-1.5 A of z stretched every wedge bond instead.
586
+ sign = 1.0 if flag == 1 else -1.0
587
+ scale = math.cos(_DIRECT_WEDGE_TILT)
515
588
  conf.SetAtomPosition(
516
589
  e,
517
590
  rdGeometry.Point3D(
518
- float(pos.x),
519
- float(pos.y),
520
- float(pos.z) + (1.5 if flag == 1 else -1.5),
591
+ float(start.x) + vx * scale,
592
+ float(start.y) + vy * scale,
593
+ float(start.z) + sign * length * math.sin(_DIRECT_WEDGE_TILT),
521
594
  ),
522
595
  )
523
596
 
597
+ # The sketch sits wherever it was drawn on the canvas, which can be hundreds
598
+ # of angstroms from the origin; centre it before handing it to the 3D view.
599
+ _center_conformer(conf)
600
+
524
601
  mol.RemoveAllConformers()
525
602
  mol.AddConformer(conf, assignId=True)
526
603
 
@@ -883,9 +883,11 @@ class EditActionsManager:
883
883
  self.host.set_current_file_path(None)
884
884
  self.host.state_manager.update_window_title()
885
885
 
886
- # Reset 2D zoom
886
+ # Reset 2D zoom to the launch default (75%), not the bare identity
887
+ # transform -- resetTransform() left the view at 100%, so Clear All
888
+ # came back zoomed in compared to a freshly started session.
887
889
  if self.host.init_manager.view_2d:
888
- self.host.init_manager.view_2d.resetTransform()
890
+ self.host.view_3d_manager.reset_zoom()
889
891
 
890
892
  # Update scene and view
891
893
  self.host.init_manager.scene.update()
@@ -24,6 +24,7 @@ from .bond_item import BondItem
24
24
 
25
25
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
26
26
 
27
+ from ..core.mol_geometry import is_problematic_valence
27
28
  from ..utils.constants import DEFAULT_BOND_LENGTH, SUM_TOLERANCE
28
29
 
29
30
 
@@ -197,6 +198,106 @@ class TemplateMixin:
197
198
 
198
199
  return not atom1_has_other_double and not atom2_has_other_double
199
200
 
201
+ @staticmethod
202
+ def _bond_load(atom: Optional[AtomItem]) -> float:
203
+ """Total bond order already attached to *atom*."""
204
+ if atom is None:
205
+ return 0.0
206
+ return sum(b.order for b in atom.bonds)
207
+
208
+ def _plan_template_bonds(
209
+ self,
210
+ bonds: List[Tuple[int, int, int]],
211
+ vertex_atoms: List[Optional[AtomItem]],
212
+ ) -> Tuple[List[Tuple[int, int, int]], List[Tuple[int, int]]]:
213
+ """Decide the order every template bond ends up with once placed.
214
+
215
+ Two things constrain the template's own alternation:
216
+
217
+ * a bond that already exists is kept, unless the benzene policy allows
218
+ re-ordering it *and* both atoms have room for the extra order;
219
+ * a Kekule ring always carries a double next to its fused bond, so
220
+ fusing onto a single bond whose atoms each already hold a double --
221
+ every peripheral single bond of naphthalene -- would hand one of them
222
+ a fifth bond. Those bonds are lowered instead, which leaves the new
223
+ carbons as methylene: a real molecule rather than a hypervalent one.
224
+
225
+ Existing bonds are settled first so that an allowed upgrade is in the
226
+ atom's load before the new bonds hanging off it are fitted, and loads
227
+ are carried across the ring so several new bonds on one atom count
228
+ together.
229
+
230
+ Returns the planned orders and the vertex pairs the editor already
231
+ draws unchanged -- the ghost skips those, since painting a second copy
232
+ over a real double bond reads as a triple.
233
+ """
234
+ is_benzene_template = len(bonds) == 6 and any(o == 2 for _, _, o in bonds)
235
+ loads = {i: self._bond_load(atom) for i, atom in enumerate(vertex_atoms)}
236
+
237
+ def vertex(index: int) -> Optional[AtomItem]:
238
+ return vertex_atoms[index] if index < len(vertex_atoms) else None
239
+
240
+ def fits(index: int, extra: float) -> bool:
241
+ atom = vertex(index)
242
+ if atom is None or extra <= 0:
243
+ return True
244
+ return not is_problematic_valence(
245
+ getattr(atom, "symbol", "C"),
246
+ loads.get(index, 0.0) + extra,
247
+ int(getattr(atom, "charge", 0) or 0),
248
+ )
249
+
250
+ planned: Dict[int, int] = {}
251
+ unchanged: List[Tuple[int, int]] = []
252
+
253
+ # Every new bond needs at least a single, so an upgrade has to leave
254
+ # room for the ones still to come as well as for itself
255
+ incoming: Dict[int, int] = {}
256
+ for i, j, _order in bonds:
257
+ atom_i, atom_j = vertex(i), vertex(j)
258
+ if atom_i and atom_j and self.find_bond_between(atom_i, atom_j):
259
+ continue
260
+ incoming[i] = incoming.get(i, 0) + 1
261
+ incoming[j] = incoming.get(j, 0) + 1
262
+
263
+ # Pass 1 -- bonds that are already drawn
264
+ for position, (i, j, order) in enumerate(bonds):
265
+ atom_i, atom_j = vertex(i), vertex(j)
266
+ exist_b = (
267
+ self.find_bond_between(atom_i, atom_j) if atom_i and atom_j else None
268
+ )
269
+ if exist_b is None:
270
+ continue
271
+ current = int(getattr(exist_b, "order", order))
272
+ extra = order - current
273
+ if (
274
+ is_benzene_template
275
+ and self._should_overwrite_benzene_bond(exist_b)
276
+ and fits(i, extra + incoming.get(i, 0))
277
+ and fits(j, extra + incoming.get(j, 0))
278
+ ):
279
+ loads[i] = loads.get(i, 0.0) + extra
280
+ loads[j] = loads.get(j, 0.0) + extra
281
+ planned[position] = order
282
+ else:
283
+ planned[position] = current
284
+ unchanged.append((i, j))
285
+
286
+ # Pass 2 -- bonds the placement will create
287
+ for position, (i, j, order) in enumerate(bonds):
288
+ if position in planned:
289
+ continue
290
+ while order > 1 and not (fits(i, order) and fits(j, order)):
291
+ order -= 1
292
+ loads[i] = loads.get(i, 0.0) + order
293
+ loads[j] = loads.get(j, 0.0) + order
294
+ planned[position] = order
295
+
296
+ return (
297
+ [(i, j, planned[position]) for position, (i, j, _) in enumerate(bonds)],
298
+ unchanged,
299
+ )
300
+
200
301
  def add_molecule_fragment(
201
302
  self,
202
303
  points: List[Union[QPointF, Tuple[float, float]]],
@@ -214,8 +315,6 @@ class TemplateMixin:
214
315
  num_points = len(points)
215
316
  atom_items: List[Optional[AtomItem]] = [None] * num_points
216
317
 
217
- is_benzene_template = num_points == 6 and any(o == 2 for _, _, o in bonds_info)
218
-
219
318
  def coords(p: Any) -> Any:
220
319
  if hasattr(p, "x") and hasattr(p, "y"):
221
320
  return (p.x(), p.y())
@@ -317,6 +416,12 @@ class TemplateMixin:
317
416
  new_tb.append((i_idx, j_idx, new_order))
318
417
  template_bonds_to_use = new_tb
319
418
 
419
+ # Atoms exist but carry no template bonds yet, so their loads are the
420
+ # ones the fusion has to fit into.
421
+ template_bonds_to_use, _unchanged = self._plan_template_bonds(
422
+ list(template_bonds_to_use), atom_items
423
+ )
424
+
320
425
  # --- 5) Bond Creation/Update ---
321
426
  for id1_idx, id2_idx, order in template_bonds_to_use:
322
427
  if id1_idx < len(atom_items) and id2_idx < len(atom_items):
@@ -331,22 +436,15 @@ class TemplateMixin:
331
436
  exist_b = self.find_bond_between(a_item, b_item)
332
437
 
333
438
  if exist_b:
334
- # Keep existing bonds by default unless it's a safe benzene overwrite
335
- should_overwrite = (
336
- is_benzene_template
337
- and self._should_overwrite_benzene_bond(exist_b)
338
- )
339
-
340
- if should_overwrite:
341
- # Update bond order if conditions met
342
- exist_b.order = order
343
- exist_b.stereo = 0
344
- self.data.bonds[(id1, id2)]["order"] = order
345
- self.data.bonds[(id1, id2)]["stereo"] = 0
346
- exist_b.update()
347
- else:
348
- # Keep existing bond if conditions not met
439
+ # _plan_template_bonds already applied the keep-or-overwrite
440
+ # policy, so an unchanged order means keep this bond
441
+ if order == exist_b.order:
349
442
  continue
443
+ exist_b.order = order
444
+ exist_b.stereo = 0
445
+ self.data.bonds[(id1, id2)]["order"] = order
446
+ self.data.bonds[(id1, id2)]["stereo"] = 0
447
+ exist_b.update()
350
448
  else:
351
449
  # Create new bond
352
450
  self.create_bond(a_item, b_item, bond_order=order, bond_stereo=0)
@@ -514,7 +612,12 @@ class TemplateMixin:
514
612
  for m, (i, j, _) in enumerate(bonds_info)
515
613
  ]
516
614
 
517
- self.template_preview.set_geometry(points, is_aromatic, preview_bonds)
615
+ preview_bonds, editor_drawn = self._plan_template_bonds(
616
+ preview_bonds, vertex_atoms
617
+ )
618
+ self.template_preview.set_geometry(
619
+ points, is_aromatic, preview_bonds, editor_drawn
620
+ )
518
621
 
519
622
  self.template_preview.show()
520
623
  if self.views():
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import logging
16
16
  import os
17
- from typing import Any, Callable, Dict, List, TYPE_CHECKING
17
+ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
18
18
 
19
19
  from PyQt6.QtGui import QAction, QIcon, QKeySequence
20
20
  from PyQt6.QtWidgets import QFileDialog, QMenu, QMessageBox
@@ -38,8 +38,18 @@ class PluginMenuManager:
38
38
  # so the rebuild pass can find and remove exactly those on the next reload.
39
39
  _PLUGIN_ACTION_TAG = "plugin_managed"
40
40
 
41
+ # Title of the menu that hosts "Plugin/<path>" registrations.
42
+ _PLUGIN_MENU_TITLE = "Plugin"
43
+
44
+ # Value of add_menu_action(pin=...) requesting the header slot: placed with
45
+ # "Plugin Manager..." above the first divider, for plugins that manage the
46
+ # plugin system itself rather than acting on a molecule.
47
+ _PIN_HEADER = "header"
48
+
41
49
  def __init__(self, init_manager: MainInitManager) -> None:
42
50
  self._im = init_manager
51
+ # Built once and re-added on every reset; see _reset_plugin_menu.
52
+ self._manage_action: Optional[QAction] = None
43
53
 
44
54
  def _make_safe_callback(self, callback: Callable, plugin_name: str) -> Callable:
45
55
  """Wrap a plugin callback so exceptions don't propagate into Qt's signal machinery."""
@@ -99,12 +109,8 @@ class PluginMenuManager:
99
109
  if not self._im.host.plugin_manager:
100
110
  return
101
111
 
102
- self._clear_all_plugin_actions(plugin_menu)
103
-
104
- manage_action = QAction("Plugin Manager...", self._im.host)
105
- manage_action.triggered.connect(lambda: self._show_plugin_manager(plugin_menu))
106
- plugin_menu.addAction(manage_action)
107
- plugin_menu.addSeparator()
112
+ self._clear_all_plugin_actions()
113
+ self._reset_plugin_menu(plugin_menu)
108
114
 
109
115
  plugins = self._im.host.plugin_manager.discover_plugins(self._im.host)
110
116
 
@@ -117,6 +123,33 @@ class PluginMenuManager:
117
123
  self.integrate_plugin_analysis_tools()
118
124
  self.integrate_plugin_optimization_methods()
119
125
 
126
+ def _reset_plugin_menu(self, plugin_menu: QMenu) -> None:
127
+ """Empty the Plugin menu and re-add its fixed header.
128
+
129
+ Menu entries are parented to the host, not to the menu, so ``clear()``
130
+ only detaches them — without retiring the outgoing ones every rebuild
131
+ strands another set on the main window for the life of the process.
132
+
133
+ The manager entry is kept and re-added rather than rebuilt: a rebuild
134
+ can start from inside its own ``triggered`` handler (Plugin Manager ▸
135
+ Reload), and that handler is still on the stack, so this is the one
136
+ action that must not be deleted here.
137
+ """
138
+ manage_action = self._manage_action
139
+ self._retire_menu_tree(plugin_menu, manage_action)
140
+ plugin_menu.clear()
141
+
142
+ if manage_action is None:
143
+ # Bound to the menu it was first built for, which in the app is
144
+ # always MainInitManager.plugin_menu.
145
+ manage_action = QAction("Plugin Manager...", self._im.host)
146
+ manage_action.triggered.connect(
147
+ lambda: self._show_plugin_manager(plugin_menu)
148
+ )
149
+ self._manage_action = manage_action
150
+ plugin_menu.addAction(manage_action)
151
+ plugin_menu.addSeparator()
152
+
120
153
  def rebuild_plugin_menus(self) -> None:
121
154
  """Fully rebuild all plugin-managed UI after an install/uninstall.
122
155
 
@@ -124,43 +157,35 @@ class PluginMenuManager:
124
157
  ``PluginManager.discover_plugins`` has already been called and the
125
158
  registries are fresh. It cleans the existing tagged actions and
126
159
  re-populates every integration point: menus, toolbars, export,
127
- file-openers, analysis tools, and 3D styles.
128
- """
160
+ file-openers, analysis tools, legacy run() entries, and 3D styles.
129
161
 
130
- def _clean_menu(menu: QMenu) -> None:
131
- for action in list(menu.actions()):
132
- submenu = action.menu()
133
- if submenu is not None:
134
- _clean_menu(submenu)
135
- if not any(not a.isSeparator() for a in submenu.actions()):
136
- menu.removeAction(action)
137
- elif action.data() == self._PLUGIN_ACTION_TAG:
138
- self._remove_action(menu, action)
162
+ The Plugin menu is emptied and rebuilt rather than topped up, so a
163
+ reload lands the context-injected entries above the folder-derived ones
164
+ exactly as a fresh launch does. Appending to the surviving (untagged)
165
+ folder entries instead used to flip the two groups on every reload.
166
+ """
139
167
 
140
168
  try:
141
169
  for menu in self._get_plugin_target_menus():
142
- _clean_menu(menu)
170
+ self._strip_plugin_actions(menu)
143
171
  except Exception:
144
172
  logging.warning("Plugin rebuild: menu cleanup error", exc_info=True)
145
173
 
146
- # Drop tagged (plugin-created) top-level menus the clean pass emptied
147
- try:
148
- menu_bar = self._im.host.menuBar()
149
- for top_action in list(menu_bar.actions()):
150
- if top_action.data() != self._PLUGIN_ACTION_TAG:
151
- continue
152
- submenu = top_action.menu()
153
- if submenu is None or not any(
154
- not a.isSeparator() for a in submenu.actions()
155
- ):
156
- menu_bar.removeAction(top_action)
157
- except Exception:
158
- logging.warning("Plugin rebuild: menubar cleanup error", exc_info=True)
174
+ self._clear_plugin_menubar_entries()
159
175
 
160
- self._im.plugin_menubar_separator_added = False
176
+ plugin_menu = getattr(self._im, "plugin_menu", None)
177
+ if plugin_menu is not None:
178
+ self._reset_plugin_menu(plugin_menu)
179
+
180
+ def _rebuild_legacy_actions() -> None:
181
+ if plugin_menu is not None:
182
+ self._add_legacy_plugin_actions(
183
+ plugin_menu, self._im.host.plugin_manager.plugins
184
+ )
161
185
 
162
186
  for method, label in [
163
187
  (self.add_registered_plugin_actions, "menu actions"),
188
+ (_rebuild_legacy_actions, "legacy plugin actions"),
164
189
  (self.add_plugin_toolbar_actions, "toolbar actions"),
165
190
  (self.integrate_plugin_export_actions, "export actions"),
166
191
  (self.integrate_plugin_file_openers, "file openers"),
@@ -173,6 +198,129 @@ class PluginMenuManager:
173
198
  except Exception:
174
199
  logging.warning("Plugin rebuild: %s error", label, exc_info=True)
175
200
 
201
+ def _retire_menu_tree(self, menu: QMenu, keep: Optional[QAction]) -> None:
202
+ """Destroy a menu subtree that ``clear()`` would only detach.
203
+
204
+ ``clear()`` deletes the actions a menu owns, but plugin entries are
205
+ parented to the host and submenus are child widgets of the menu, so
206
+ both outlive it — a submenu's menuAction going away does not take the
207
+ QMenu with it. Retiring the tree explicitly keeps a reload from
208
+ stranding another copy of every folder submenu and its entries.
209
+
210
+ *keep* is spared: it is the manager entry, which may be the handler
211
+ the current rebuild was started from.
212
+ """
213
+ for action in menu.actions():
214
+ submenu = action.menu()
215
+ if submenu is not None:
216
+ self._retire_menu_tree(submenu, keep)
217
+ submenu.deleteLater()
218
+ elif action is not keep:
219
+ action.deleteLater()
220
+
221
+ def _strip_plugin_actions(self, menu: QMenu) -> None:
222
+ """Remove every tagged action from *menu* and its submenus.
223
+
224
+ Recurses before removing, so a plugin-created submenu is emptied
225
+ first and only then dropped — and destroyed with it. Detaching a
226
+ submenu without destroying it leaves the QMenu alive as a child of
227
+ its former parent, and the next populate pass, no longer finding it
228
+ among the parent's actions, builds another: one stranded menu per
229
+ rebuild for the life of the process.
230
+
231
+ Native submenus that happen to be empty are not ours to remove, hence
232
+ the tag check.
233
+ """
234
+ for action in list(menu.actions()):
235
+ submenu = action.menu()
236
+ if submenu is not None:
237
+ self._strip_plugin_actions(submenu)
238
+ if action.data() == self._PLUGIN_ACTION_TAG and not any(
239
+ not a.isSeparator() for a in submenu.actions()
240
+ ):
241
+ menu.removeAction(action)
242
+ submenu.deleteLater()
243
+ elif action.data() == self._PLUGIN_ACTION_TAG:
244
+ self._remove_action(menu, action)
245
+
246
+ def _clear_plugin_menubar_entries(self) -> None:
247
+ """Drop emptied plugin-created top-level menus and the menubar divider.
248
+
249
+ Both clear paths need this. ``_get_plugin_target_menus`` walks *into*
250
+ the menus on the bar but never the bar itself, so without this pass an
251
+ uninstalled plugin's own top-level menu survives as an empty stub and
252
+ the divider in front of it is never reclaimed. Resetting the flag lets
253
+ the following populate pass re-add that divider.
254
+ """
255
+ try:
256
+ menu_bar = self._im.host.menuBar()
257
+ for top_action in list(menu_bar.actions()):
258
+ if top_action.data() != self._PLUGIN_ACTION_TAG:
259
+ continue
260
+ submenu = top_action.menu()
261
+ if submenu is None or not any(
262
+ not a.isSeparator() for a in submenu.actions()
263
+ ):
264
+ menu_bar.removeAction(top_action)
265
+ except Exception:
266
+ logging.warning("Plugin menubar cleanup error", exc_info=True)
267
+
268
+ self._im.plugin_menubar_separator_added = False
269
+
270
+ def _ensure_plugin_separator(self, menu: QMenu) -> None:
271
+ """Add a tagged divider before the first plugin entry appended to *menu*.
272
+
273
+ The divider must land where the plugin content actually starts. For a
274
+ nested path like ``Tools/Sub/Item`` that is the *parent* menu, just
275
+ before ``Sub`` is created -- checking the freshly made (and therefore
276
+ empty) leaf instead placed no divider at all, and the next sibling
277
+ entry then saw an untagged submenu as the last action and inserted the
278
+ divider after the plugin submenu, splitting the plugin group instead of
279
+ separating it from the native entries.
280
+ """
281
+ actions = menu.actions()
282
+ if (
283
+ actions
284
+ and not actions[-1].isSeparator()
285
+ and actions[-1].data() != self._PLUGIN_ACTION_TAG
286
+ ):
287
+ sep = menu.addSeparator()
288
+ if sep is not None:
289
+ sep.setData(self._PLUGIN_ACTION_TAG)
290
+
291
+ @classmethod
292
+ def _wants_header_slot(
293
+ cls, top_level_title: str, parts: List[str], action_def: Dict[str, Any]
294
+ ) -> bool:
295
+ """Return True for an entry that asked to sit beside the Plugin Manager.
296
+
297
+ A plugin that manages the plugin system itself — the Plugin Installer —
298
+ reads as part of the menu's header pair rather than as one more
299
+ installed plugin, and says so with ``pin="header"``. The app therefore
300
+ needs to know no plugin by name.
301
+ """
302
+ return (
303
+ top_level_title == cls._PLUGIN_MENU_TITLE
304
+ and len(parts) == 2
305
+ and action_def.get("pin") == cls._PIN_HEADER
306
+ )
307
+
308
+ @staticmethod
309
+ def _trim_trailing_separator(menu: QMenu) -> None:
310
+ """Drop a divider left dangling at the end of *menu*."""
311
+ actions = menu.actions()
312
+ if actions and actions[-1].isSeparator():
313
+ menu.removeAction(actions[-1])
314
+
315
+ @staticmethod
316
+ def _add_beside_plugin_manager(menu: QMenu, action: QAction) -> None:
317
+ """Add *action* to the menu's header group, above the first divider."""
318
+ anchor = next((a for a in menu.actions() if a.isSeparator()), None)
319
+ if anchor is None:
320
+ menu.addAction(action)
321
+ else:
322
+ menu.insertAction(anchor, action)
323
+
176
324
  def add_registered_plugin_actions(self) -> None:
177
325
  """Add menu actions explicitly registered by V3/V4 plugins."""
178
326
  if not self._im.host.plugin_manager.menu_actions:
@@ -211,25 +359,31 @@ class PluginMenuManager:
211
359
  ),
212
360
  None,
213
361
  )
214
- current_menu = sub if sub else current_menu.addMenu(part)
215
-
216
- actions = current_menu.actions()
217
- if (
218
- actions
219
- and not actions[-1].isSeparator()
220
- and actions[-1].data() != self._PLUGIN_ACTION_TAG
221
- ):
222
- sep = current_menu.addSeparator()
223
- sep.setData(self._PLUGIN_ACTION_TAG)
362
+ if sub is not None:
363
+ # Descending into an existing submenu appends nothing to
364
+ # the parent, so no divider is due here.
365
+ current_menu = sub
366
+ continue
367
+ self._ensure_plugin_separator(current_menu)
368
+ current_menu = current_menu.addMenu(part)
369
+ current_menu.menuAction().setData(self._PLUGIN_ACTION_TAG)
370
+
371
+ label = text or parts[-1]
372
+ pinned = self._wants_header_slot(top_level_title, parts, action_def)
373
+ if not pinned:
374
+ self._ensure_plugin_separator(current_menu)
224
375
 
225
- action = QAction(text or parts[-1], self._im.host)
376
+ action = QAction(label, self._im.host)
226
377
  action.triggered.connect(
227
378
  self._make_safe_callback(callback, action_def.get("plugin", "Plugin"))
228
379
  )
229
380
  if action_def.get("shortcut"):
230
381
  action.setShortcut(QKeySequence(action_def["shortcut"]))
231
382
  action.setData(self._PLUGIN_ACTION_TAG)
232
- current_menu.addAction(action)
383
+ if pinned:
384
+ self._add_beside_plugin_manager(current_menu, action)
385
+ else:
386
+ current_menu.addAction(action)
233
387
 
234
388
  def add_plugin_toolbar_actions(self) -> None:
235
389
  """Populate the plugin toolbar from registered toolbar actions."""
@@ -271,21 +425,17 @@ class PluginMenuManager:
271
425
  dlg.exec()
272
426
  self.update_plugin_menu(plugin_menu)
273
427
 
274
- def _clear_all_plugin_actions(self, plugin_menu: QMenu) -> None:
275
- """Remove all tagged plugin actions from every menu and the export button."""
276
-
277
- def clear_menu(menu: Any) -> None:
278
- if not menu:
279
- return
280
- for act in list(menu.actions()):
281
- if act.data() == self._PLUGIN_ACTION_TAG:
282
- self._remove_action(menu, act)
283
- elif act.menu():
284
- clear_menu(act.menu())
428
+ def _clear_all_plugin_actions(self) -> None:
429
+ """Remove all tagged plugin actions from every menu and the export button.
285
430
 
286
- plugin_menu.clear()
431
+ The Plugin menu itself is not emptied here — ``_reset_plugin_menu``
432
+ owns that, so both the update and the rebuild path clear it the same
433
+ way.
434
+ """
287
435
  for menu in self._get_plugin_target_menus():
288
- clear_menu(menu)
436
+ if menu:
437
+ self._strip_plugin_actions(menu)
438
+ self._clear_plugin_menubar_entries()
289
439
 
290
440
  def update_style_menu_with_plugins(self) -> None:
291
441
  """Append custom 3D styles registered by plugins to the style menu."""
@@ -329,15 +479,36 @@ class PluginMenuManager:
329
479
  root = []
330
480
  for p in plugins:
331
481
  if hasattr(p["module"], "run"):
332
- cat = p.get("category", p.get("rel_folder", "")).strip()
482
+ cat = str(p.get("category") or "").strip()
333
483
  if cat:
334
484
  categorized.setdefault(cat, []).append(p)
335
485
  else:
336
486
  root.append(p)
337
487
 
488
+ # This is the last step of Plugin-menu construction on both the update
489
+ # and the rebuild path, so it also drops the header divider when there
490
+ # turns out to be nothing under it -- an install of only the Plugin
491
+ # Installer would otherwise end on a dangling divider.
492
+ if not categorized and not root:
493
+ self._trim_trailing_separator(plugin_menu)
494
+ return
495
+
496
+ # Divide the folder-derived entries below from the context-injected
497
+ # ones ("Plugin/XXX" paths) that add_registered_plugin_actions already
498
+ # placed above: two different organizing schemes should not read as one
499
+ # list. Deferred until we know there is content, so an app with only
500
+ # context-injected plugins gets no dangling trailing divider.
501
+ existing = plugin_menu.actions()
502
+ if existing and not existing[-1].isSeparator():
503
+ plugin_menu.addSeparator()
504
+
338
505
  for cat in sorted(categorized.keys()):
339
506
  current_parent: Any = plugin_menu
340
- for part in cat.split(os.sep):
507
+ # A folder category arrives with os.sep; a root plugin's own
508
+ # PLUGIN_CATEGORY is hand-written and usually uses "/".
509
+ for part in cat.replace("/", os.sep).split(os.sep):
510
+ if not part:
511
+ continue
341
512
  sub = next(
342
513
  (
343
514
  a.menu()
@@ -54,6 +54,7 @@ class TemplatePreviewItem(QGraphicsItem):
54
54
  self.ghost_atoms: List[AtomItem] = []
55
55
  self.ghost_bonds: List[BondItem] = []
56
56
  self.existing_indices: Set[int] = set()
57
+ self.editor_drawn_bonds: Set[frozenset] = set()
57
58
  self.existing_h_counts: Dict[int, int] = {}
58
59
  self.replaced_label_path = QPainterPath()
59
60
  self.mark_first_atom = False
@@ -66,11 +67,13 @@ class TemplatePreviewItem(QGraphicsItem):
66
67
  points: list[QPointF],
67
68
  is_aromatic: bool = False,
68
69
  bonds_info: Optional[list[Any]] = None,
70
+ editor_drawn_bonds: Optional[list[Any]] = None,
69
71
  ) -> None:
70
72
  """Set polygon points for a standard ring template preview.
71
73
 
72
74
  ``bonds_info`` carries the orders the placement will use; without it the
73
- ring falls back to a plain alternation.
75
+ ring falls back to a plain alternation. ``editor_drawn_bonds`` lists the
76
+ vertex pairs the editor already draws, which the ghost must leave alone.
74
77
  """
75
78
  self.prepareGeometryChange()
76
79
  self.is_chain = False
@@ -83,6 +86,10 @@ class TemplatePreviewItem(QGraphicsItem):
83
86
  for i in range(n)
84
87
  ]
85
88
  self._rebuild_ghost(points, bonds_info, [{"symbol": "C"} for _ in points])
89
+ # After the rebuild, which clears whatever the last preview recorded
90
+ self.editor_drawn_bonds = {
91
+ frozenset(pair) for pair in (editor_drawn_bonds or ())
92
+ }
86
93
  self.update()
87
94
 
88
95
  def set_chain_geometry(
@@ -130,6 +137,7 @@ class TemplatePreviewItem(QGraphicsItem):
130
137
  """Drop the ghost molecule and everything derived from the editor scene."""
131
138
  self._clear_ghost_items()
132
139
  self.existing_indices = set()
140
+ self.editor_drawn_bonds = set()
133
141
  self.existing_h_counts = {}
134
142
  self.replaced_label_path = QPainterPath()
135
143
 
@@ -140,6 +148,9 @@ class TemplatePreviewItem(QGraphicsItem):
140
148
  atoms_data: list[dict[str, Any]],
141
149
  ) -> None:
142
150
  """Rebuild the preview as real atom/bond items so it renders like the result."""
151
+ # Every preview kind lands here, so this is the one place that can keep
152
+ # a user template from inheriting the pairs a fused ring recorded
153
+ self.editor_drawn_bonds = set()
143
154
  self.ghost_scene.host = self.scene()
144
155
  if not points:
145
156
  self._clear_ghost()
@@ -362,6 +373,11 @@ class TemplatePreviewItem(QGraphicsItem):
362
373
  painter.save()
363
374
  painter.setOpacity(GHOST_OPACITY)
364
375
  for bond in self.ghost_bonds:
376
+ pair = frozenset((bond.atom1.atom_id, bond.atom2.atom_id))
377
+ if pair in self.editor_drawn_bonds:
378
+ # Placement leaves this bond alone and the editor already draws
379
+ # it; a ghost copy on top of a real double bond reads as a triple
380
+ continue
365
381
  painter.save()
366
382
  painter.translate(bond.pos())
367
383
  bond.paint(painter, option) # type: ignore[arg-type]
File without changes