MoleditPy-linux 2.1.1__py3-none-any.whl → 2.2.1__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.
@@ -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,48 @@ 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 {"name": str, "module": module_obj}
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.drop_handlers = [] # List of (priority, plugin_name, callback)
44
+
45
+ # Extended Registries (Added to prevent lazy initialization "monkey patching")
46
+ self.export_actions = []
47
+ self.optimization_methods = {}
48
+ self.file_openers = {}
49
+ self.analysis_tools = []
50
+ self.save_handlers = {}
51
+ self.load_handlers = {}
52
+ self.custom_3d_styles = {} # style_name -> {'plugin': name, 'callback': func}
53
+
54
+ def get_main_window(self):
55
+ return self.main_window
56
+
57
+ def set_main_window(self, mw):
58
+ self.main_window = mw
21
59
 
22
60
  def ensure_plugin_dir(self):
23
- """Creates the plugin directory if it creates doesn't exist."""
61
+ """Creates the plugin directory if it doesn't exist."""
24
62
  if not os.path.exists(self.plugin_dir):
25
63
  try:
26
64
  os.makedirs(self.plugin_dir)
@@ -32,14 +70,42 @@ class PluginManager:
32
70
  self.ensure_plugin_dir()
33
71
  QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
34
72
 
73
+ def install_plugin(self, file_path):
74
+ """Copies a plugin file to the plugin directory."""
75
+ self.ensure_plugin_dir()
76
+ try:
77
+ filename = os.path.basename(file_path)
78
+ dest_path = os.path.join(self.plugin_dir, filename)
79
+ shutil.copy2(file_path, dest_path)
80
+ # Reload plugins after install
81
+ if self.main_window:
82
+ self.discover_plugins(self.main_window)
83
+ return True, f"Installed {filename}"
84
+ except Exception as e:
85
+ return False, str(e)
86
+
35
87
  def discover_plugins(self, parent=None):
36
88
  """
37
- Recursively scans the plugin directory for .py files and attempts to import them.
38
- Ignores __pycache__ and other directories starting with "__".
39
- Returns a list of valid loaded plugins.
89
+ Recursively scans the plugin directory.
90
+ Supports both legacy autorun(parent) and new initialize(context).
40
91
  """
92
+ if parent:
93
+ self.main_window = parent
94
+
41
95
  self.ensure_plugin_dir()
42
96
  self.plugins = []
97
+ self.menu_actions = []
98
+ self.toolbar_actions = []
99
+ self.drop_handlers = []
100
+
101
+ # Clear extended registries
102
+ self.export_actions = []
103
+ self.optimization_methods = {}
104
+ self.file_openers = {}
105
+ self.analysis_tools = []
106
+ self.save_handlers = {}
107
+ self.load_handlers = {}
108
+ self.custom_3d_styles = {}
43
109
 
44
110
  if not os.path.exists(self.plugin_dir):
45
111
  return []
@@ -51,17 +117,11 @@ class PluginManager:
51
117
  for filename in files:
52
118
  if filename.endswith(".py") and not filename.startswith("__"):
53
119
  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
120
  rel_folder = os.path.relpath(root, self.plugin_dir)
59
121
  if rel_folder == '.':
60
122
  rel_folder = ""
61
123
 
62
124
  try:
63
- # Unique module name based on file path to avoid conflicts
64
- # e.g. plugins.subdir.myplugin
65
125
  module_name = os.path.splitext(os.path.relpath(filepath, self.plugin_dir))[0].replace(os.sep, '.')
66
126
 
67
127
  spec = importlib.util.spec_from_file_location(module_name, filepath)
@@ -70,51 +130,180 @@ class PluginManager:
70
130
  sys.modules[spec.name] = module
71
131
  spec.loader.exec_module(module)
72
132
 
73
- # Check for required attributes
133
+ # --- Metadata Extraction ---
74
134
  plugin_name = getattr(module, 'PLUGIN_NAME', filename[:-3])
135
+ plugin_version = getattr(module, 'PLUGIN_VERSION', getattr(module, '__version__', 'Unknown'))
136
+ plugin_author = getattr(module, 'PLUGIN_AUTHOR', getattr(module, '__author__', 'Unknown'))
137
+ plugin_desc = getattr(module, 'PLUGIN_DESCRIPTION', getattr(module, '__doc__', ''))
75
138
 
76
- # Valid plugin if it has 'run' OR 'autorun'
139
+ # Clean up docstring if used as description
140
+ if plugin_desc:
141
+ plugin_desc = plugin_desc.strip().split('\n')[0]
142
+
143
+ # check for interface compliance
77
144
  has_run = hasattr(module, 'run') and callable(module.run)
78
145
  has_autorun = hasattr(module, 'autorun') and callable(module.autorun)
79
-
80
- if has_run:
81
- self.plugins.append({
82
- 'name': plugin_name,
83
- 'module': module,
84
- 'rel_folder': rel_folder
85
- })
146
+ has_init = hasattr(module, 'initialize') and callable(module.initialize)
147
+
148
+ status = "Loaded"
86
149
 
87
- if has_autorun:
150
+ # Execute loading logic
151
+ if has_init:
152
+ context = PluginContext(self, plugin_name)
153
+ try:
154
+ module.initialize(context)
155
+ except Exception as e:
156
+ status = f"Error (Init): {e}"
157
+ print(f"Plugin {plugin_name} initialize error: {e}")
158
+ traceback.print_exc()
159
+ elif has_autorun:
88
160
  try:
89
- if parent:
90
- module.autorun(parent)
161
+ if self.main_window:
162
+ module.autorun(self.main_window)
91
163
  else:
92
- print(f"Skipping autorun for {plugin_name}: parent not provided.")
164
+ status = "Skipped (No MW)"
93
165
  except Exception as e:
94
- print(f"Error executing autorun for {filename}: {e}")
166
+ status = f"Error (Autorun): {e}"
167
+ print(f"Plugin {plugin_name} autorun error: {e}")
95
168
  traceback.print_exc()
169
+ elif not has_run:
170
+ status = "No Entry Point"
96
171
 
97
- if not has_run and not has_autorun:
98
- print(f"Plugin {filename} skipped: Missing 'run(main_window)' or 'autorun(main_window)' function.")
99
-
172
+ self.plugins.append({
173
+ 'name': plugin_name,
174
+ 'version': plugin_version,
175
+ 'author': plugin_author,
176
+ 'description': plugin_desc,
177
+ 'module': module,
178
+ 'rel_folder': rel_folder,
179
+ 'status': status,
180
+ 'filepath': filepath,
181
+ 'has_run': has_run # for menu manual run
182
+ })
183
+
100
184
  except Exception as e:
101
- # Robust error handling
102
- msg = f"Failed to load plugin {filename}:\n{e}"
103
- print(msg)
185
+ print(f"Failed to load plugin {filename}: {e}")
104
186
  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
187
 
111
188
  return self.plugins
112
189
 
113
190
  def run_plugin(self, module, main_window):
114
- """Executes the plugin's run method."""
191
+ """Executes the plugin's run method (Legacy manual trigger)."""
115
192
  try:
116
193
  module.run(main_window)
117
194
  except Exception as e:
118
195
  QMessageBox.critical(main_window, "Plugin Error", f"Error running plugin '{getattr(module, 'PLUGIN_NAME', 'Unknown')}':\n{e}")
119
196
  traceback.print_exc()
120
197
 
198
+ # --- Registration Callbacks ---
199
+ def register_menu_action(self, plugin_name, path, callback, text, icon, shortcut):
200
+ self.menu_actions.append({
201
+ 'plugin': plugin_name, 'path': path, 'callback': callback,
202
+ 'text': text, 'icon': icon, 'shortcut': shortcut
203
+ })
204
+
205
+ def register_toolbar_action(self, plugin_name, callback, text, icon, tooltip):
206
+ self.toolbar_actions.append({
207
+ 'plugin': plugin_name, 'callback': callback,
208
+ 'text': text, 'icon': icon, 'tooltip': tooltip
209
+ })
210
+
211
+
212
+
213
+ def register_drop_handler(self, plugin_name, callback, priority):
214
+ self.drop_handlers.append({
215
+ 'priority': priority, 'plugin': plugin_name, 'callback': callback
216
+ })
217
+ # Sort by priority desc
218
+ self.drop_handlers.sort(key=lambda x: x['priority'], reverse=True)
219
+
220
+ def register_export_action(self, plugin_name, label, callback):
221
+ self.export_actions.append({
222
+ 'plugin': plugin_name, 'label': label, 'callback': callback
223
+ })
224
+
225
+ def register_optimization_method(self, plugin_name, method_name, callback):
226
+ # Key by upper-case method name for consistency
227
+ self.optimization_methods[method_name.upper()] = {
228
+ 'plugin': plugin_name, 'callback': callback, 'label': method_name
229
+ }
230
+
231
+ def register_file_opener(self, plugin_name, extension, callback):
232
+ # Normalize extension to lowercase
233
+ ext = extension.lower()
234
+ if not ext.startswith('.'):
235
+ ext = '.' + ext
236
+ self.file_openers[ext] = {
237
+ 'plugin': plugin_name, 'callback': callback
238
+ }
239
+
240
+ # Analysis Tools registration
241
+ def register_analysis_tool(self, plugin_name, label, callback):
242
+ self.analysis_tools.append({'plugin': plugin_name, 'label': label, 'callback': callback})
243
+
244
+ # State Persistence registration
245
+ def register_save_handler(self, plugin_name, callback):
246
+ self.save_handlers[plugin_name] = callback
247
+
248
+ def register_load_handler(self, plugin_name, callback):
249
+ self.load_handlers[plugin_name] = callback
250
+
251
+ def register_3d_style(self, plugin_name, style_name, callback):
252
+ self.custom_3d_styles[style_name] = {
253
+ 'plugin': plugin_name, 'callback': callback
254
+ }
255
+
256
+ def get_plugin_info_safe(self, file_path):
257
+ """Extracts plugin metadata using AST parsing (safe, no execution)."""
258
+ info = {
259
+ 'name': os.path.basename(file_path),
260
+ 'version': 'Unknown',
261
+ 'author': 'Unknown',
262
+ 'description': ''
263
+ }
264
+ try:
265
+ with open(file_path, "r", encoding="utf-8") as f:
266
+ tree = ast.parse(f.read())
267
+
268
+ for node in tree.body:
269
+ if isinstance(node, ast.Assign):
270
+ for target in node.targets:
271
+ if isinstance(target, ast.Name):
272
+ # Helper to extract value
273
+ val = None
274
+ if isinstance(node.value, ast.Constant): # Py3.8+
275
+ val = node.value.value
276
+ elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
277
+ val = node.value.s
278
+
279
+ if val is not None:
280
+ if target.id == 'PLUGIN_NAME':
281
+ info['name'] = val
282
+ elif target.id == 'PLUGIN_VERSION':
283
+ info['version'] = val
284
+ elif target.id == 'PLUGIN_AUTHOR':
285
+ info['author'] = val
286
+ elif target.id == 'PLUGIN_DESCRIPTION':
287
+ info['description'] = val
288
+ elif target.id == '__version__' and info['version'] == 'Unknown':
289
+ info['version'] = val
290
+ elif target.id == '__author__' and info['author'] == 'Unknown':
291
+ info['author'] = val
292
+
293
+ # Docstring extraction
294
+ if isinstance(node, ast.Expr):
295
+ val = None
296
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
297
+ val = node.value.value
298
+ elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
299
+ val = node.value.s
300
+
301
+ if val and not info['description']:
302
+ info['description'] = val.strip().split('\n')[0]
303
+
304
+ except Exception as e:
305
+ print(f"Error parsing plugin info: {e}")
306
+ return info
307
+
308
+
309
+
@@ -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
- # Bond preview color may also follow the configured bond color
92
- try:
93
- bond_hex = self.scene().window.settings.get('bond_color', '#222222')
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.accept)
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-linux
3
- Version: 2.1.1
3
+ Version: 2.2.1
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