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