MoleditPy 2.1.1__py3-none-any.whl → 2.2.0a0__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/modules/constants.py +1 -1
- moleditpy/modules/main_window.py +8 -0
- moleditpy/modules/main_window_app_state.py +24 -0
- moleditpy/modules/main_window_compute.py +27 -0
- moleditpy/modules/main_window_main_init.py +274 -56
- moleditpy/modules/main_window_ui_manager.py +12 -1
- moleditpy/modules/main_window_view_3d.py +122 -35
- moleditpy/modules/molecule_scene.py +6 -14
- moleditpy/modules/plugin_interface.py +204 -0
- moleditpy/modules/plugin_manager.py +220 -38
- moleditpy/modules/plugin_manager_window.py +218 -0
- moleditpy/modules/template_preview_item.py +3 -6
- moleditpy/modules/user_template_dialog.py +59 -1
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/METADATA +1 -1
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/RECORD +19 -17
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/WHEEL +0 -0
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.1.1.dist-info → moleditpy-2.2.0a0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
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
|
+
|
|
4
13
|
"""
|
|
5
14
|
plugin_manager.py
|
|
6
15
|
Manages discovery, loading, and execution of external plugins.
|
|
@@ -8,19 +17,50 @@ Manages discovery, loading, and execution of external plugins.
|
|
|
8
17
|
|
|
9
18
|
import os
|
|
10
19
|
import sys
|
|
20
|
+
import shutil
|
|
11
21
|
import importlib.util
|
|
12
22
|
import traceback
|
|
23
|
+
import ast
|
|
13
24
|
from PyQt6.QtGui import QDesktopServices
|
|
14
25
|
from PyQt6.QtCore import QUrl
|
|
15
26
|
from PyQt6.QtWidgets import QMessageBox
|
|
16
27
|
|
|
28
|
+
try:
|
|
29
|
+
from .plugin_interface import PluginContext
|
|
30
|
+
except ImportError:
|
|
31
|
+
# Fallback if running as script
|
|
32
|
+
from modules.plugin_interface import PluginContext
|
|
33
|
+
|
|
17
34
|
class PluginManager:
|
|
18
|
-
def __init__(self):
|
|
35
|
+
def __init__(self, main_window=None):
|
|
19
36
|
self.plugin_dir = os.path.join(os.path.expanduser('~'), '.moleditpy', 'plugins')
|
|
20
|
-
self.plugins = [] # List of
|
|
37
|
+
self.plugins = [] # List of dicts
|
|
38
|
+
self.main_window = main_window
|
|
39
|
+
|
|
40
|
+
# Registries for actions
|
|
41
|
+
self.menu_actions = [] # List of (plugin_name, path, callback, text, icon, shortcut)
|
|
42
|
+
self.toolbar_actions = []
|
|
43
|
+
self.context_menu_3d_actions = []
|
|
44
|
+
self.drop_handlers = [] # List of (priority, plugin_name, callback)
|
|
45
|
+
|
|
46
|
+
# Extended Registries (Added to prevent lazy initialization "monkey patching")
|
|
47
|
+
self.export_actions = []
|
|
48
|
+
self.optimization_methods = {}
|
|
49
|
+
self.file_openers = {}
|
|
50
|
+
self.analysis_tools = []
|
|
51
|
+
self.save_handlers = {}
|
|
52
|
+
self.save_handlers = {}
|
|
53
|
+
self.load_handlers = {}
|
|
54
|
+
self.custom_3d_styles = {} # style_name -> {'plugin': name, 'callback': func}
|
|
55
|
+
|
|
56
|
+
def get_main_window(self):
|
|
57
|
+
return self.main_window
|
|
58
|
+
|
|
59
|
+
def set_main_window(self, mw):
|
|
60
|
+
self.main_window = mw
|
|
21
61
|
|
|
22
62
|
def ensure_plugin_dir(self):
|
|
23
|
-
"""Creates the plugin directory if it
|
|
63
|
+
"""Creates the plugin directory if it doesn't exist."""
|
|
24
64
|
if not os.path.exists(self.plugin_dir):
|
|
25
65
|
try:
|
|
26
66
|
os.makedirs(self.plugin_dir)
|
|
@@ -32,14 +72,33 @@ class PluginManager:
|
|
|
32
72
|
self.ensure_plugin_dir()
|
|
33
73
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
|
|
34
74
|
|
|
75
|
+
def install_plugin(self, file_path):
|
|
76
|
+
"""Copies a plugin file to the plugin directory."""
|
|
77
|
+
self.ensure_plugin_dir()
|
|
78
|
+
try:
|
|
79
|
+
filename = os.path.basename(file_path)
|
|
80
|
+
dest_path = os.path.join(self.plugin_dir, filename)
|
|
81
|
+
shutil.copy2(file_path, dest_path)
|
|
82
|
+
# Reload plugins after install
|
|
83
|
+
if self.main_window:
|
|
84
|
+
self.discover_plugins(self.main_window)
|
|
85
|
+
return True, f"Installed {filename}"
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return False, str(e)
|
|
88
|
+
|
|
35
89
|
def discover_plugins(self, parent=None):
|
|
36
90
|
"""
|
|
37
|
-
Recursively scans the plugin directory
|
|
38
|
-
|
|
39
|
-
Returns a list of valid loaded plugins.
|
|
91
|
+
Recursively scans the plugin directory.
|
|
92
|
+
Supports both legacy autorun(parent) and new initialize(context).
|
|
40
93
|
"""
|
|
94
|
+
if parent:
|
|
95
|
+
self.main_window = parent
|
|
96
|
+
|
|
41
97
|
self.ensure_plugin_dir()
|
|
42
98
|
self.plugins = []
|
|
99
|
+
self.menu_actions = []
|
|
100
|
+
self.toolbar_actions = []
|
|
101
|
+
self.drop_handlers = []
|
|
43
102
|
|
|
44
103
|
if not os.path.exists(self.plugin_dir):
|
|
45
104
|
return []
|
|
@@ -51,17 +110,11 @@ class PluginManager:
|
|
|
51
110
|
for filename in files:
|
|
52
111
|
if filename.endswith(".py") and not filename.startswith("__"):
|
|
53
112
|
filepath = os.path.join(root, filename)
|
|
54
|
-
|
|
55
|
-
# Calculate relative folder path for menu structure
|
|
56
|
-
# equivalent to: rel_path = os.path.relpath(root, self.plugin_dir)
|
|
57
|
-
# if root is plugin_dir, rel_path is '.'
|
|
58
113
|
rel_folder = os.path.relpath(root, self.plugin_dir)
|
|
59
114
|
if rel_folder == '.':
|
|
60
115
|
rel_folder = ""
|
|
61
116
|
|
|
62
117
|
try:
|
|
63
|
-
# Unique module name based on file path to avoid conflicts
|
|
64
|
-
# e.g. plugins.subdir.myplugin
|
|
65
118
|
module_name = os.path.splitext(os.path.relpath(filepath, self.plugin_dir))[0].replace(os.sep, '.')
|
|
66
119
|
|
|
67
120
|
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
|
@@ -70,51 +123,180 @@ class PluginManager:
|
|
|
70
123
|
sys.modules[spec.name] = module
|
|
71
124
|
spec.loader.exec_module(module)
|
|
72
125
|
|
|
73
|
-
#
|
|
126
|
+
# --- Metadata Extraction ---
|
|
74
127
|
plugin_name = getattr(module, 'PLUGIN_NAME', filename[:-3])
|
|
128
|
+
plugin_version = getattr(module, 'PLUGIN_VERSION', getattr(module, '__version__', 'Unknown'))
|
|
129
|
+
plugin_author = getattr(module, 'PLUGIN_AUTHOR', getattr(module, '__author__', 'Unknown'))
|
|
130
|
+
plugin_desc = getattr(module, 'PLUGIN_DESCRIPTION', getattr(module, '__doc__', ''))
|
|
75
131
|
|
|
76
|
-
#
|
|
132
|
+
# Clean up docstring if used as description
|
|
133
|
+
if plugin_desc:
|
|
134
|
+
plugin_desc = plugin_desc.strip().split('\n')[0]
|
|
135
|
+
|
|
136
|
+
# check for interface compliance
|
|
77
137
|
has_run = hasattr(module, 'run') and callable(module.run)
|
|
78
138
|
has_autorun = hasattr(module, 'autorun') and callable(module.autorun)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
'name': plugin_name,
|
|
83
|
-
'module': module,
|
|
84
|
-
'rel_folder': rel_folder
|
|
85
|
-
})
|
|
139
|
+
has_init = hasattr(module, 'initialize') and callable(module.initialize)
|
|
140
|
+
|
|
141
|
+
status = "Loaded"
|
|
86
142
|
|
|
87
|
-
|
|
143
|
+
# Execute loading logic
|
|
144
|
+
if has_init:
|
|
145
|
+
context = PluginContext(self, plugin_name)
|
|
146
|
+
try:
|
|
147
|
+
module.initialize(context)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
status = f"Error (Init): {e}"
|
|
150
|
+
print(f"Plugin {plugin_name} initialize error: {e}")
|
|
151
|
+
traceback.print_exc()
|
|
152
|
+
elif has_autorun:
|
|
88
153
|
try:
|
|
89
|
-
if
|
|
90
|
-
module.autorun(
|
|
154
|
+
if self.main_window:
|
|
155
|
+
module.autorun(self.main_window)
|
|
91
156
|
else:
|
|
92
|
-
|
|
157
|
+
status = "Skipped (No MW)"
|
|
93
158
|
except Exception as e:
|
|
94
|
-
|
|
159
|
+
status = f"Error (Autorun): {e}"
|
|
160
|
+
print(f"Plugin {plugin_name} autorun error: {e}")
|
|
95
161
|
traceback.print_exc()
|
|
162
|
+
elif not has_run:
|
|
163
|
+
status = "No Entry Point"
|
|
96
164
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
165
|
+
self.plugins.append({
|
|
166
|
+
'name': plugin_name,
|
|
167
|
+
'version': plugin_version,
|
|
168
|
+
'author': plugin_author,
|
|
169
|
+
'description': plugin_desc,
|
|
170
|
+
'module': module,
|
|
171
|
+
'rel_folder': rel_folder,
|
|
172
|
+
'status': status,
|
|
173
|
+
'filepath': filepath,
|
|
174
|
+
'has_run': has_run # for menu manual run
|
|
175
|
+
})
|
|
176
|
+
|
|
100
177
|
except Exception as e:
|
|
101
|
-
|
|
102
|
-
msg = f"Failed to load plugin {filename}:\n{e}"
|
|
103
|
-
print(msg)
|
|
178
|
+
print(f"Failed to load plugin {filename}: {e}")
|
|
104
179
|
traceback.print_exc()
|
|
105
|
-
if parent:
|
|
106
|
-
# Use print/status bar instead of popups for non-critical failures during bulk load?
|
|
107
|
-
# For now, keep it visible but maybe less intrusive if many fail?
|
|
108
|
-
# sticking to original logic just in catch block
|
|
109
|
-
pass
|
|
110
180
|
|
|
111
181
|
return self.plugins
|
|
112
182
|
|
|
113
183
|
def run_plugin(self, module, main_window):
|
|
114
|
-
"""Executes the plugin's run method."""
|
|
184
|
+
"""Executes the plugin's run method (Legacy manual trigger)."""
|
|
115
185
|
try:
|
|
116
186
|
module.run(main_window)
|
|
117
187
|
except Exception as e:
|
|
118
188
|
QMessageBox.critical(main_window, "Plugin Error", f"Error running plugin '{getattr(module, 'PLUGIN_NAME', 'Unknown')}':\n{e}")
|
|
119
189
|
traceback.print_exc()
|
|
120
190
|
|
|
191
|
+
# --- Registration Callbacks ---
|
|
192
|
+
def register_menu_action(self, plugin_name, path, callback, text, icon, shortcut):
|
|
193
|
+
self.menu_actions.append({
|
|
194
|
+
'plugin': plugin_name, 'path': path, 'callback': callback,
|
|
195
|
+
'text': text, 'icon': icon, 'shortcut': shortcut
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
def register_toolbar_action(self, plugin_name, callback, text, icon, tooltip):
|
|
199
|
+
self.toolbar_actions.append({
|
|
200
|
+
'plugin': plugin_name, 'callback': callback,
|
|
201
|
+
'text': text, 'icon': icon, 'tooltip': tooltip
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def register_drop_handler(self, plugin_name, callback, priority):
|
|
207
|
+
self.drop_handlers.append({
|
|
208
|
+
'priority': priority, 'plugin': plugin_name, 'callback': callback
|
|
209
|
+
})
|
|
210
|
+
# Sort by priority desc
|
|
211
|
+
self.drop_handlers.sort(key=lambda x: x['priority'], reverse=True)
|
|
212
|
+
|
|
213
|
+
def register_export_action(self, plugin_name, label, callback):
|
|
214
|
+
self.export_actions.append({
|
|
215
|
+
'plugin': plugin_name, 'label': label, 'callback': callback
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
def register_optimization_method(self, plugin_name, method_name, callback):
|
|
219
|
+
# Key by upper-case method name for consistency
|
|
220
|
+
self.optimization_methods[method_name.upper()] = {
|
|
221
|
+
'plugin': plugin_name, 'callback': callback, 'label': method_name
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def register_file_opener(self, plugin_name, extension, callback):
|
|
225
|
+
# Normalize extension to lowercase
|
|
226
|
+
ext = extension.lower()
|
|
227
|
+
if not ext.startswith('.'):
|
|
228
|
+
ext = '.' + ext
|
|
229
|
+
self.file_openers[ext] = {
|
|
230
|
+
'plugin': plugin_name, 'callback': callback
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Analysis Tools registration
|
|
234
|
+
def register_analysis_tool(self, plugin_name, label, callback):
|
|
235
|
+
self.analysis_tools.append({'plugin': plugin_name, 'label': label, 'callback': callback})
|
|
236
|
+
|
|
237
|
+
# State Persistence registration
|
|
238
|
+
def register_save_handler(self, plugin_name, callback):
|
|
239
|
+
self.save_handlers[plugin_name] = callback
|
|
240
|
+
|
|
241
|
+
def register_load_handler(self, plugin_name, callback):
|
|
242
|
+
self.load_handlers[plugin_name] = callback
|
|
243
|
+
|
|
244
|
+
def register_3d_style(self, plugin_name, style_name, callback):
|
|
245
|
+
self.custom_3d_styles[style_name] = {
|
|
246
|
+
'plugin': plugin_name, 'callback': callback
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def get_plugin_info_safe(self, file_path):
|
|
250
|
+
"""Extracts plugin metadata using AST parsing (safe, no execution)."""
|
|
251
|
+
info = {
|
|
252
|
+
'name': os.path.basename(file_path),
|
|
253
|
+
'version': 'Unknown',
|
|
254
|
+
'author': 'Unknown',
|
|
255
|
+
'description': ''
|
|
256
|
+
}
|
|
257
|
+
try:
|
|
258
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
259
|
+
tree = ast.parse(f.read())
|
|
260
|
+
|
|
261
|
+
for node in tree.body:
|
|
262
|
+
if isinstance(node, ast.Assign):
|
|
263
|
+
for target in node.targets:
|
|
264
|
+
if isinstance(target, ast.Name):
|
|
265
|
+
# Helper to extract value
|
|
266
|
+
val = None
|
|
267
|
+
if isinstance(node.value, ast.Constant): # Py3.8+
|
|
268
|
+
val = node.value.value
|
|
269
|
+
elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
|
|
270
|
+
val = node.value.s
|
|
271
|
+
|
|
272
|
+
if val is not None:
|
|
273
|
+
if target.id == 'PLUGIN_NAME':
|
|
274
|
+
info['name'] = val
|
|
275
|
+
elif target.id == 'PLUGIN_VERSION':
|
|
276
|
+
info['version'] = val
|
|
277
|
+
elif target.id == 'PLUGIN_AUTHOR':
|
|
278
|
+
info['author'] = val
|
|
279
|
+
elif target.id == 'PLUGIN_DESCRIPTION':
|
|
280
|
+
info['description'] = val
|
|
281
|
+
elif target.id == '__version__' and info['version'] == 'Unknown':
|
|
282
|
+
info['version'] = val
|
|
283
|
+
elif target.id == '__author__' and info['author'] == 'Unknown':
|
|
284
|
+
info['author'] = val
|
|
285
|
+
|
|
286
|
+
# Docstring extraction
|
|
287
|
+
if isinstance(node, ast.Expr):
|
|
288
|
+
val = None
|
|
289
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
290
|
+
val = node.value.value
|
|
291
|
+
elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
|
|
292
|
+
val = node.value.s
|
|
293
|
+
|
|
294
|
+
if val and not info['description']:
|
|
295
|
+
info['description'] = val.strip().split('\n')[0]
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
print(f"Error parsing plugin info: {e}")
|
|
299
|
+
return info
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from PyQt6.QtWidgets import (
|
|
16
|
+
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QTableWidget,
|
|
17
|
+
QTableWidgetItem, QHeaderView, QLabel, QFileDialog, QMessageBox, QAbstractItemView
|
|
18
|
+
)
|
|
19
|
+
from PyQt6.QtCore import Qt, QMimeData, QUrl
|
|
20
|
+
from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QDesktopServices
|
|
21
|
+
|
|
22
|
+
class PluginManagerWindow(QDialog):
|
|
23
|
+
def __init__(self, plugin_manager, parent=None):
|
|
24
|
+
super().__init__(parent)
|
|
25
|
+
self.plugin_manager = plugin_manager
|
|
26
|
+
self.setWindowTitle("Plugin Manager")
|
|
27
|
+
self.resize(800, 500)
|
|
28
|
+
self.setAcceptDrops(True) # Enable drag & drop for the whole window
|
|
29
|
+
|
|
30
|
+
self.init_ui()
|
|
31
|
+
self.refresh_plugin_list()
|
|
32
|
+
|
|
33
|
+
def init_ui(self):
|
|
34
|
+
layout = QVBoxLayout(self)
|
|
35
|
+
|
|
36
|
+
# Header / Instruction
|
|
37
|
+
lbl_info = QLabel("Drag & Drop .py files here to install plugins.")
|
|
38
|
+
lbl_info.setStyleSheet("color: gray; font-style: italic;")
|
|
39
|
+
layout.addWidget(lbl_info)
|
|
40
|
+
|
|
41
|
+
# Plugin Table
|
|
42
|
+
self.table = QTableWidget()
|
|
43
|
+
self.table.setColumnCount(6)
|
|
44
|
+
self.table.setHorizontalHeaderLabels(["Status", "Name", "Version", "Author", "Location", "Description"])
|
|
45
|
+
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
46
|
+
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive)
|
|
47
|
+
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch) # Description stretches
|
|
48
|
+
self.table.setColumnWidth(1, 200) # Make Name column wider
|
|
49
|
+
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
50
|
+
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
51
|
+
self.table.itemSelectionChanged.connect(self.update_button_state)
|
|
52
|
+
self.table.itemDoubleClicked.connect(self.show_plugin_details)
|
|
53
|
+
layout.addWidget(self.table)
|
|
54
|
+
|
|
55
|
+
# Buttons
|
|
56
|
+
btn_layout = QHBoxLayout()
|
|
57
|
+
|
|
58
|
+
btn_reload = QPushButton("Reload Plugins")
|
|
59
|
+
btn_reload.clicked.connect(self.on_reload)
|
|
60
|
+
btn_layout.addWidget(btn_reload)
|
|
61
|
+
|
|
62
|
+
btn_folder = QPushButton("Open Plugin Folder")
|
|
63
|
+
btn_folder.clicked.connect(self.plugin_manager.open_plugin_folder)
|
|
64
|
+
btn_layout.addWidget(btn_folder)
|
|
65
|
+
|
|
66
|
+
self.btn_remove = QPushButton("Remove Plugin")
|
|
67
|
+
self.btn_remove.clicked.connect(self.on_remove_plugin)
|
|
68
|
+
self.btn_remove.setEnabled(False)
|
|
69
|
+
btn_layout.addWidget(self.btn_remove)
|
|
70
|
+
|
|
71
|
+
btn_explore = QPushButton("Explore Plugins Online")
|
|
72
|
+
btn_explore.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://hiroyokoyama.github.io/moleditpy-plugins/explorer/")))
|
|
73
|
+
btn_layout.addWidget(btn_explore)
|
|
74
|
+
|
|
75
|
+
btn_close = QPushButton("Close")
|
|
76
|
+
btn_close.clicked.connect(self.close)
|
|
77
|
+
btn_layout.addStretch()
|
|
78
|
+
btn_layout.addWidget(btn_close)
|
|
79
|
+
|
|
80
|
+
layout.addLayout(btn_layout)
|
|
81
|
+
|
|
82
|
+
def refresh_plugin_list(self):
|
|
83
|
+
self.table.setRowCount(0)
|
|
84
|
+
plugins = self.plugin_manager.plugins
|
|
85
|
+
|
|
86
|
+
self.table.setRowCount(len(plugins))
|
|
87
|
+
for row, p in enumerate(plugins):
|
|
88
|
+
self.table.setItem(row, 0, QTableWidgetItem(str(p.get('status', 'Unknown'))))
|
|
89
|
+
self.table.setItem(row, 1, QTableWidgetItem(str(p.get('name', 'Unknown'))))
|
|
90
|
+
self.table.setItem(row, 2, QTableWidgetItem(str(p.get('version', ''))))
|
|
91
|
+
self.table.setItem(row, 3, QTableWidgetItem(str(p.get('author', ''))))
|
|
92
|
+
|
|
93
|
+
# Location (Relative Path)
|
|
94
|
+
full_path = p.get('filepath', '')
|
|
95
|
+
rel_path = ""
|
|
96
|
+
if full_path:
|
|
97
|
+
try:
|
|
98
|
+
rel_path = os.path.relpath(full_path, self.plugin_manager.plugin_dir)
|
|
99
|
+
except Exception:
|
|
100
|
+
rel_path = os.path.basename(full_path)
|
|
101
|
+
self.table.setItem(row, 4, QTableWidgetItem(str(rel_path)))
|
|
102
|
+
|
|
103
|
+
self.table.setItem(row, 5, QTableWidgetItem(str(p.get('description', ''))))
|
|
104
|
+
|
|
105
|
+
# Simple color coding for status
|
|
106
|
+
status = str(p.get('status', ''))
|
|
107
|
+
color = None
|
|
108
|
+
if status.startswith("Error"):
|
|
109
|
+
color = Qt.GlobalColor.red
|
|
110
|
+
elif status == "Loaded":
|
|
111
|
+
color = Qt.GlobalColor.green
|
|
112
|
+
elif status == "No Entry Point":
|
|
113
|
+
color = Qt.GlobalColor.gray
|
|
114
|
+
|
|
115
|
+
if color:
|
|
116
|
+
self.table.item(row, 0).setForeground(color)
|
|
117
|
+
|
|
118
|
+
def update_button_state(self):
|
|
119
|
+
has_selection = (self.table.currentRow() >= 0)
|
|
120
|
+
if hasattr(self, 'btn_remove'):
|
|
121
|
+
self.btn_remove.setEnabled(has_selection)
|
|
122
|
+
|
|
123
|
+
def on_reload(self):
|
|
124
|
+
# Trigger reload in main manager
|
|
125
|
+
if self.plugin_manager.main_window:
|
|
126
|
+
self.plugin_manager.discover_plugins(self.plugin_manager.main_window)
|
|
127
|
+
self.refresh_plugin_list()
|
|
128
|
+
# Also update main window menu if possible, but that might require a callback or signal
|
|
129
|
+
# For now we assume discover_plugins re-runs autoruns which might duplicate stuff if not careful?
|
|
130
|
+
# Actually discover_plugins clears lists, so re-running is safe logic-wise,
|
|
131
|
+
# but main_window need to rebuild its menu.
|
|
132
|
+
# We will handle UI rebuild in the main window code by observing or callback.
|
|
133
|
+
|
|
134
|
+
# For immediate feedback:
|
|
135
|
+
QMessageBox.information(self, "Reloaded", "Plugins have been reloaded.")
|
|
136
|
+
else:
|
|
137
|
+
self.plugin_manager.discover_plugins()
|
|
138
|
+
self.refresh_plugin_list()
|
|
139
|
+
|
|
140
|
+
def on_remove_plugin(self):
|
|
141
|
+
row = self.table.currentRow()
|
|
142
|
+
if row < 0:
|
|
143
|
+
QMessageBox.warning(self, "Warning", "Please select a plugin to remove.")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Assuming table row index matches plugins list index (confirmed in refresh_plugin_list)
|
|
147
|
+
if row < len(self.plugin_manager.plugins):
|
|
148
|
+
plugin = self.plugin_manager.plugins[row]
|
|
149
|
+
filepath = plugin.get('filepath')
|
|
150
|
+
|
|
151
|
+
if filepath and os.path.exists(filepath):
|
|
152
|
+
reply = QMessageBox.question(self, "Remove Plugin",
|
|
153
|
+
f"Are you sure you want to remove '{plugin.get('name', 'Unknown')}'?\n\nFile: {filepath}\nThis cannot be undone.",
|
|
154
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
155
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
156
|
+
try:
|
|
157
|
+
os.remove(filepath)
|
|
158
|
+
self.on_reload() # Reload list and plugins
|
|
159
|
+
QMessageBox.information(self, "Success", f"Removed '{plugin.get('name', 'Unknown')}'.")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
QMessageBox.critical(self, "Error", f"Failed to delete file: {e}")
|
|
162
|
+
else:
|
|
163
|
+
QMessageBox.warning(self, "Error", f"Plugin file not found:\n{filepath}")
|
|
164
|
+
|
|
165
|
+
def show_plugin_details(self, item):
|
|
166
|
+
row = item.row()
|
|
167
|
+
if row < len(self.plugin_manager.plugins):
|
|
168
|
+
p = self.plugin_manager.plugins[row]
|
|
169
|
+
msg = f"Name: {p.get('name', 'Unknown')}\n" \
|
|
170
|
+
f"Version: {p.get('version', 'Unknown')}\n" \
|
|
171
|
+
f"Author: {p.get('author', 'Unknown')}\n" \
|
|
172
|
+
f"Status: {p.get('status', 'Unknown')}\n" \
|
|
173
|
+
f"Location: {p.get('filepath', 'Unknown')}\n\n" \
|
|
174
|
+
f"Description:\n{p.get('description', 'No description available.')}"
|
|
175
|
+
QMessageBox.information(self, "Plugin Details", msg)
|
|
176
|
+
|
|
177
|
+
# --- Drag & Drop Support ---
|
|
178
|
+
def dragEnterEvent(self, event: QDragEnterEvent):
|
|
179
|
+
if event.mimeData().hasUrls():
|
|
180
|
+
event.accept()
|
|
181
|
+
else:
|
|
182
|
+
event.ignore()
|
|
183
|
+
|
|
184
|
+
def dropEvent(self, event: QDropEvent):
|
|
185
|
+
files_installed = []
|
|
186
|
+
errors = []
|
|
187
|
+
for url in event.mimeData().urls():
|
|
188
|
+
file_path = url.toLocalFile()
|
|
189
|
+
if os.path.isfile(file_path) and file_path.endswith('.py'):
|
|
190
|
+
# Extract info and confirm
|
|
191
|
+
info = self.plugin_manager.get_plugin_info_safe(file_path)
|
|
192
|
+
msg = (f"Do you want to install this plugin?\n\n"
|
|
193
|
+
f"Name: {info['name']}\n"
|
|
194
|
+
f"Author: {info['author']}\n"
|
|
195
|
+
f"Version: {info['version']}\n"
|
|
196
|
+
f"Description: {info['description']}\n\n"
|
|
197
|
+
f"File: {os.path.basename(file_path)}")
|
|
198
|
+
|
|
199
|
+
reply = QMessageBox.question(self, "Install Plugin?", msg,
|
|
200
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
201
|
+
|
|
202
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
203
|
+
success, msg = self.plugin_manager.install_plugin(file_path)
|
|
204
|
+
if success:
|
|
205
|
+
files_installed.append(msg)
|
|
206
|
+
else:
|
|
207
|
+
errors.append(msg)
|
|
208
|
+
|
|
209
|
+
if files_installed or errors:
|
|
210
|
+
self.refresh_plugin_list()
|
|
211
|
+
summary = ""
|
|
212
|
+
if files_installed:
|
|
213
|
+
summary += "Installed:\n" + "\n".join(files_installed) + "\n\n"
|
|
214
|
+
if errors:
|
|
215
|
+
summary += "Errors:\n" + "\n".join(errors)
|
|
216
|
+
|
|
217
|
+
QMessageBox.information(self, "Plugin Installation", summary)
|
|
218
|
+
|
|
@@ -88,12 +88,9 @@ class TemplatePreviewItem(QGraphicsItem):
|
|
|
88
88
|
return
|
|
89
89
|
|
|
90
90
|
# Draw bonds first with better visibility
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
bond_pen = QPen(QColor(bond_hex), 2.5)
|
|
95
|
-
except Exception:
|
|
96
|
-
bond_pen = QPen(QColor(100, 100, 100, 200), 2.5)
|
|
91
|
+
# Draw bonds first with better visibility
|
|
92
|
+
# Use gray (ghost) color for template preview to distinguish from real bonds
|
|
93
|
+
bond_pen = QPen(QColor(80, 80, 80, 180), 2.5)
|
|
97
94
|
painter.setPen(bond_pen)
|
|
98
95
|
|
|
99
96
|
for bond_info in self.user_template_bonds:
|
|
@@ -81,10 +81,68 @@ class UserTemplateDialog(QDialog):
|
|
|
81
81
|
button_layout.addWidget(self.delete_button)
|
|
82
82
|
|
|
83
83
|
close_button = QPushButton("Close")
|
|
84
|
-
close_button.clicked.connect(self.
|
|
84
|
+
close_button.clicked.connect(self.close)
|
|
85
85
|
button_layout.addWidget(close_button)
|
|
86
86
|
|
|
87
87
|
layout.addLayout(button_layout)
|
|
88
|
+
|
|
89
|
+
def closeEvent(self, event):
|
|
90
|
+
"""ダイアログクローズ時にモードをリセット"""
|
|
91
|
+
self.cleanup_template_mode()
|
|
92
|
+
super().closeEvent(event)
|
|
93
|
+
|
|
94
|
+
def cleanup_template_mode(self):
|
|
95
|
+
"""テンプレートモードを終了し、atom_C(炭素描画)モードに戻す (Defensive implementation)"""
|
|
96
|
+
# 1. Reset Dialog State
|
|
97
|
+
self.selected_template = None
|
|
98
|
+
if hasattr(self, 'delete_button'):
|
|
99
|
+
self.delete_button.setEnabled(False)
|
|
100
|
+
|
|
101
|
+
# 2. Reset Main Window Mode (UI/Toolbar)
|
|
102
|
+
target_mode = 'atom_C'
|
|
103
|
+
try:
|
|
104
|
+
if hasattr(self.main_window, 'set_mode_and_update_toolbar'):
|
|
105
|
+
self.main_window.set_mode_and_update_toolbar(target_mode)
|
|
106
|
+
elif hasattr(self.main_window, 'set_mode'):
|
|
107
|
+
self.main_window.set_mode(target_mode)
|
|
108
|
+
|
|
109
|
+
# Fallback: set attribute directly if methods fail/don't exist
|
|
110
|
+
if hasattr(self.main_window, 'mode'):
|
|
111
|
+
self.main_window.mode = target_mode
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logging.error(f"Error resetting main window mode: {e}")
|
|
114
|
+
|
|
115
|
+
# 3. Reset Scene State (The Source of Truth)
|
|
116
|
+
try:
|
|
117
|
+
if hasattr(self.main_window, 'scene') and self.main_window.scene:
|
|
118
|
+
scene = self.main_window.scene
|
|
119
|
+
|
|
120
|
+
# A. FORCE MODE
|
|
121
|
+
scene.mode = target_mode
|
|
122
|
+
scene.current_atom_symbol = 'C'
|
|
123
|
+
|
|
124
|
+
# B. Clear Data
|
|
125
|
+
if hasattr(scene, 'user_template_data'):
|
|
126
|
+
scene.user_template_data = None
|
|
127
|
+
if hasattr(scene, 'template_context'):
|
|
128
|
+
scene.template_context = {}
|
|
129
|
+
|
|
130
|
+
# C. Clear/Hide Preview Item
|
|
131
|
+
if hasattr(scene, 'clear_template_preview'):
|
|
132
|
+
scene.clear_template_preview()
|
|
133
|
+
|
|
134
|
+
if hasattr(scene, 'template_preview') and scene.template_preview:
|
|
135
|
+
scene.template_preview.hide()
|
|
136
|
+
|
|
137
|
+
# D. Reset Cursor & View
|
|
138
|
+
if scene.views():
|
|
139
|
+
view = scene.views()[0]
|
|
140
|
+
view.setCursor(Qt.CursorShape.CrossCursor)
|
|
141
|
+
view.viewport().update()
|
|
142
|
+
|
|
143
|
+
scene.update()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logging.error(f"Error cleaning up scene state: {e}")
|
|
88
146
|
|
|
89
147
|
def resizeEvent(self, event):
|
|
90
148
|
"""ダイアログリサイズ時にテンプレートプレビューを再フィット"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0a0
|
|
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
|