MoleditPy-linux 2.2.4__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.
Files changed (58) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +293 -0
  7. moleditpy_linux/modules/alignment_dialog.py +273 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/icon.icns +0 -0
  11. moleditpy_linux/modules/assets/icon.ico +0 -0
  12. moleditpy_linux/modules/assets/icon.png +0 -0
  13. moleditpy_linux/modules/atom_item.py +348 -0
  14. moleditpy_linux/modules/bond_item.py +406 -0
  15. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  16. moleditpy_linux/modules/calculation_worker.py +766 -0
  17. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  18. moleditpy_linux/modules/constants.py +88 -0
  19. moleditpy_linux/modules/constrained_optimization_dialog.py +679 -0
  20. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  21. moleditpy_linux/modules/custom_qt_interactor.py +59 -0
  22. moleditpy_linux/modules/dialog3_d_picking_mixin.py +108 -0
  23. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  24. moleditpy_linux/modules/main_window.py +842 -0
  25. moleditpy_linux/modules/main_window_app_state.py +780 -0
  26. moleditpy_linux/modules/main_window_compute.py +1242 -0
  27. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  28. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  29. moleditpy_linux/modules/main_window_edit_actions.py +1455 -0
  30. moleditpy_linux/modules/main_window_export.py +806 -0
  31. moleditpy_linux/modules/main_window_main_init.py +2006 -0
  32. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  33. moleditpy_linux/modules/main_window_project_io.py +434 -0
  34. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  35. moleditpy_linux/modules/main_window_ui_manager.py +606 -0
  36. moleditpy_linux/modules/main_window_view_3d.py +1531 -0
  37. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  38. moleditpy_linux/modules/mirror_dialog.py +122 -0
  39. moleditpy_linux/modules/molecular_data.py +302 -0
  40. moleditpy_linux/modules/molecule_scene.py +2000 -0
  41. moleditpy_linux/modules/move_group_dialog.py +598 -0
  42. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  43. moleditpy_linux/modules/planarize_dialog.py +221 -0
  44. moleditpy_linux/modules/plugin_interface.py +195 -0
  45. moleditpy_linux/modules/plugin_manager.py +309 -0
  46. moleditpy_linux/modules/plugin_manager_window.py +221 -0
  47. moleditpy_linux/modules/settings_dialog.py +1149 -0
  48. moleditpy_linux/modules/template_preview_item.py +157 -0
  49. moleditpy_linux/modules/template_preview_view.py +74 -0
  50. moleditpy_linux/modules/translation_dialog.py +365 -0
  51. moleditpy_linux/modules/user_template_dialog.py +692 -0
  52. moleditpy_linux/modules/zoomable_view.py +129 -0
  53. moleditpy_linux-2.2.4.dist-info/METADATA +936 -0
  54. moleditpy_linux-2.2.4.dist-info/RECORD +58 -0
  55. moleditpy_linux-2.2.4.dist-info/WHEEL +5 -0
  56. moleditpy_linux-2.2.4.dist-info/entry_points.txt +2 -0
  57. moleditpy_linux-2.2.4.dist-info/licenses/LICENSE +674 -0
  58. moleditpy_linux-2.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2006 @@
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
+ """
14
+ main_window_main_init.py
15
+ MainWindow (main_window.py) から分離されたモジュール
16
+ 機能クラス: MainWindowMainInit
17
+ """
18
+
19
+
20
+ import math
21
+ import os
22
+ import json
23
+
24
+
25
+ # RDKit imports (explicit to satisfy flake8 and used features)
26
+ from rdkit import Chem
27
+ try:
28
+ pass
29
+ except Exception:
30
+ pass
31
+
32
+ # PyQt6 Modules
33
+ from PyQt6.QtWidgets import (
34
+ QApplication, QWidget, QVBoxLayout, QHBoxLayout,
35
+ QPushButton, QSplitter, QToolBar, QSizePolicy, QLabel, QToolButton, QMenu, QMessageBox, QFileDialog
36
+ )
37
+
38
+ from PyQt6.QtGui import (
39
+ QPen, QBrush, QColor, QPainter, QAction, QActionGroup, QFont, QPolygonF,
40
+ QKeySequence,
41
+ QPixmap, QIcon, QShortcut, QDesktopServices
42
+ )
43
+
44
+
45
+ from PyQt6.QtCore import (
46
+ Qt, QPointF, QRectF, QLineF, QUrl, QTimer
47
+ )
48
+ import platform
49
+ try:
50
+ import winreg
51
+ except Exception:
52
+ winreg = None
53
+
54
+ try:
55
+ from .plugin_manager import PluginManager
56
+ except Exception:
57
+ from modules.plugin_manager import PluginManager
58
+
59
+
60
+ def detect_system_dark_mode():
61
+ """Return True if the OS prefers dark app theme, False if light, or None if unknown.
62
+
63
+ This is a best-effort, cross-platform check supporting Windows (registry),
64
+ macOS (defaults read), and GNOME/GTK-based Linux (gsettings). Return
65
+ None if no reliable information is available.
66
+ """
67
+ # Delegate detailed OS detection to `detect_system_theme` and map
68
+ # 'dark' -> True, 'light' -> False. This avoids duplicating the
69
+ # registry and subprocess calls in two places.
70
+ theme = detect_system_theme()
71
+ if theme == 'dark':
72
+ return True
73
+ if theme == 'light':
74
+ return False
75
+ return None
76
+
77
+ def detect_system_theme():
78
+ """OSの優先テーマ設定を 'dark', 'light', または None として返す。
79
+
80
+ This is a best-effort, cross-platform check.
81
+ """
82
+ try:
83
+ # Windows: AppsUseLightTheme (0 = dark, 1 = light)
84
+ if platform.system() == 'Windows' and winreg is not None:
85
+ try:
86
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
87
+ r'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize') as k:
88
+ val, _ = winreg.QueryValueEx(k, 'AppsUseLightTheme')
89
+ return 'dark' if int(val) == 0 else 'light'
90
+ except Exception:
91
+ pass
92
+
93
+ # macOS: 'defaults read -g AppleInterfaceStyle'
94
+ if platform.system() == 'Darwin':
95
+ return 'light'
96
+ '''
97
+ try:
98
+ # 'defaults read ...' が成功すればダークモード
99
+ p = subprocess.run(
100
+ ['defaults', 'read', '-g', 'AppleInterfaceStyle'],
101
+ capture_output=True, text=True, check=True, encoding='utf-8'
102
+ )
103
+ if p.stdout.strip().lower() == 'dark':
104
+ return 'dark'
105
+
106
+ except subprocess.CalledProcessError:
107
+ # コマンド失敗 (キーが存在しない) = ライトモード
108
+ return 'light'
109
+ except Exception:
110
+ # その他のエラー
111
+ pass
112
+ '''
113
+
114
+ # Linux / GNOME: try color-scheme gsetting; fallback to gtk-theme detection
115
+ if platform.system() == 'Linux':
116
+ try:
117
+ p = subprocess.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], capture_output=True, text=True)
118
+ if p.returncode == 0:
119
+ out = p.stdout.strip().strip("'\n ")
120
+ if 'dark' in out.lower():
121
+ return 'dark'
122
+ if 'light' in out.lower():
123
+ return 'light'
124
+ except Exception:
125
+ pass
126
+
127
+ try:
128
+ p = subprocess.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True, text=True)
129
+ if p.returncode == 0 and '-dark' in p.stdout.lower():
130
+ return 'dark'
131
+ except Exception:
132
+ pass
133
+ except Exception:
134
+ pass
135
+ return None
136
+
137
+
138
+ # Use centralized Open Babel availability from package-level __init__
139
+ # Use per-package modules availability (local __init__).
140
+ try:
141
+ from . import OBABEL_AVAILABLE
142
+ except Exception:
143
+ from modules import OBABEL_AVAILABLE
144
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
145
+ if OBABEL_AVAILABLE:
146
+ try:
147
+ from openbabel import pybel
148
+ except Exception:
149
+ # If import fails here, disable OBABEL locally; avoid raising
150
+ pybel = None
151
+ OBABEL_AVAILABLE = False
152
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
153
+ else:
154
+ pybel = None
155
+
156
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
157
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
158
+ # it once at module import time and expose a small, robust wrapper so callers
159
+ # can avoid re-importing sip repeatedly and so we centralize exception
160
+ # handling (this reduces crash risk during teardown and deletion operations).
161
+ try:
162
+ import sip as _sip # type: ignore
163
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
164
+ except Exception:
165
+ _sip = None
166
+ _sip_isdeleted = None
167
+
168
+ try:
169
+ # package relative imports (preferred when running as `python -m moleditpy`)
170
+ from .constants import NUM_DASHES, VERSION
171
+ from .molecular_data import MolecularData
172
+ from .molecule_scene import MoleculeScene
173
+ from .zoomable_view import ZoomableView
174
+ from .color_settings_dialog import ColorSettingsDialog
175
+ from .settings_dialog import SettingsDialog
176
+ from .custom_qt_interactor import CustomQtInteractor
177
+ except Exception:
178
+ # Fallback to absolute imports for script-style execution
179
+ from modules.constants import NUM_DASHES, VERSION
180
+ from modules.molecular_data import MolecularData
181
+ from modules.molecule_scene import MoleculeScene
182
+ from modules.zoomable_view import ZoomableView
183
+ from modules.color_settings_dialog import ColorSettingsDialog
184
+ from modules.settings_dialog import SettingsDialog
185
+ from modules.custom_qt_interactor import CustomQtInteractor
186
+
187
+
188
+
189
+ # --- クラス定義 ---
190
+ class MainWindowMainInit(object):
191
+ """ main_window.py から分離された機能クラス """
192
+
193
+ # __init__ は main_window.py からコピーされます
194
+
195
+
196
+ def __init__(self, initial_file=None):
197
+ # This helper is not used as a mixin in this project; initialization
198
+ # happens on the `MainWindow` instance. Avoid calling super() here
199
+ # because we initialize the `QMainWindow` base class in
200
+ # `MainWindow.__init__` directly.
201
+ self.setAcceptDrops(True)
202
+ self.settings_dir = os.path.join(os.path.expanduser('~'), '.moleditpy')
203
+ self.settings_file = os.path.join(self.settings_dir, 'settings.json')
204
+ self.settings = {}
205
+ self.load_settings()
206
+ self.initial_settings = self.settings.copy()
207
+ self.setWindowTitle("MoleditPy Ver. " + VERSION); self.setGeometry(100, 100, 1400, 800)
208
+ self.data = MolecularData(); self.current_mol = None
209
+ self.current_3d_style = 'ball_and_stick'
210
+ self.show_chiral_labels = False
211
+ self.atom_info_display_mode = None # 'id', 'coords', 'symbol', or None
212
+ self.current_atom_info_labels = None # 現在の原子情報ラベル
213
+ self.is_3d_edit_mode = False
214
+ self.dragged_atom_info = None
215
+ self.atom_actor = None
216
+ self.is_2d_editable = True
217
+ self.is_xyz_derived = False # XYZ由来の分子かどうかのフラグ
218
+ # Chemical check flags: whether a chemical/sanitization check was attempted and whether it failed
219
+ self.chem_check_tried = False
220
+ self.chem_check_failed = False
221
+ # 3D最適化のデフォルト手法
222
+ self.optimization_method = self.settings.get('optimization_method', 'MMFF_RDKIT')
223
+ self.axes_actor = None
224
+ self.axes_widget = None
225
+ self._template_dialog = None # テンプレートダイアログの参照
226
+ self.undo_stack = []
227
+ self.redo_stack = []
228
+ self.constraints_3d = []
229
+ self.mode_actions = {}
230
+
231
+ # 保存状態を追跡する変数
232
+ self.has_unsaved_changes = False
233
+ # 設定ファイルのディスク書き込みを遅延するフラグ
234
+ # True に設定された場合、設定はメモリ上で更新され、アプリ終了時にまとめて保存されます。
235
+ self.settings_dirty = True
236
+ self.current_file_path = None # 現在開いているファイルのパス
237
+ self.initialization_complete = False # 初期化完了フラグ
238
+ # Token to invalidate pending implicit-hydrogen UI updates
239
+ self._ih_update_counter = 0
240
+
241
+ # 測定機能用の変数
242
+ self.measurement_mode = False
243
+ self.selected_atoms_for_measurement = []
244
+ self.measurement_labels = [] # (atom_idx, label_text) のタプルのリスト
245
+ self.measurement_text_actor = None
246
+ self.measurement_label_items_2d = [] # 2Dビューの測定ラベルアイテム
247
+ self.atom_id_to_rdkit_idx_map = {} # 2D原子IDから3D RDKit原子インデックスへのマッピング
248
+
249
+ # 3D原子選択用の変数
250
+ self.selected_atoms_3d = set()
251
+ self.atom_selection_mode = False
252
+ self.selected_atom_actors = []
253
+
254
+ # 3D編集用の原子選択状態 (3Dビューで選択された原子のインデックス)
255
+ self.selected_atoms_3d = set()
256
+
257
+ # 3D編集ダイアログの参照を保持
258
+ self.active_3d_dialogs = []
259
+
260
+
261
+ # プラグインマネージャーの初期化
262
+ try:
263
+ self.plugin_manager = PluginManager()
264
+ except Exception as e:
265
+ print(f"Failed to initialize PluginManager: {e}")
266
+ self.plugin_manager = None
267
+
268
+ self.init_ui()
269
+ self.init_worker_thread()
270
+ self._setup_3d_picker()
271
+
272
+ # --- RDKit初回実行コストの事前読み込み(ウォームアップ)---
273
+ try:
274
+ # Create a molecule with a variety of common atoms to ensure
275
+ # the valence/H-count machinery is fully initialized.
276
+ warmup_smiles = "OC(N)C(S)P"
277
+ warmup_mol = Chem.MolFromSmiles(warmup_smiles)
278
+ if warmup_mol:
279
+ for atom in warmup_mol.GetAtoms():
280
+ atom.GetNumImplicitHs()
281
+ except Exception as e:
282
+ print(f"RDKit warm-up failed: {e}")
283
+
284
+ self.reset_undo_stack()
285
+ self.scene.selectionChanged.connect(self.update_edit_menu_actions)
286
+ QApplication.clipboard().dataChanged.connect(self.update_edit_menu_actions)
287
+
288
+ self.update_edit_menu_actions()
289
+
290
+ if initial_file:
291
+ self.load_command_line_file(initial_file)
292
+
293
+ QTimer.singleShot(0, self.apply_initial_settings)
294
+ # カメラ初期化フラグ(初回描画時のみリセットを許可する)
295
+ self._camera_initialized = False
296
+
297
+ # 初期メニューテキストと状態を設定
298
+ self.update_atom_id_menu_text()
299
+ self.update_atom_id_menu_state()
300
+
301
+
302
+ # 初期化完了を設定
303
+ self.initialization_complete = True
304
+ self.update_window_title() # 初期化完了後にタイトルを更新
305
+ # Ensure initial keyboard/mouse focus is placed on the 2D view
306
+ # when opening a file or starting the application. This avoids
307
+ # accidental focus landing on toolbar/buttons (e.g. Optimize 2D).
308
+ try:
309
+ QTimer.singleShot(0, self.view_2d.setFocus)
310
+ except Exception:
311
+ pass
312
+
313
+
314
+
315
+ def init_ui(self):
316
+ # 1. 現在のスクリプトがあるディレクトリのパスを取得
317
+ script_dir = os.path.dirname(os.path.abspath(__file__))
318
+
319
+ # 2. 'assets'フォルダ内のアイコンファイルへのフルパスを構築
320
+ icon_path = os.path.join(script_dir, 'assets', 'icon.png')
321
+
322
+ # 3. ファイルパスから直接QIconオブジェクトを作成
323
+ if os.path.exists(icon_path): # ファイルが存在するか確認
324
+ app_icon = QIcon(icon_path)
325
+
326
+ # 4. ウィンドウにアイコンを設定
327
+ self.setWindowIcon(app_icon)
328
+ else:
329
+ print(f"警告: アイコンファイルが見つかりません: {icon_path}")
330
+
331
+
332
+
333
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
334
+ # スプリッターハンドルを太くして視認性を向上
335
+ self.splitter.setHandleWidth(8)
336
+ # スプリッターハンドルのスタイルを改善
337
+ self.splitter.setStyleSheet("""
338
+ QSplitter::handle {
339
+ background-color: #ccc;
340
+ border: 1px solid #999;
341
+ border-radius: 4px;
342
+ margin: 2px;
343
+ }
344
+ QSplitter::handle:hover {
345
+ background-color: #aaa;
346
+ }
347
+ QSplitter::handle:pressed {
348
+ background-color: #888;
349
+ }
350
+ """)
351
+ self.setCentralWidget(self.splitter)
352
+
353
+ left_pane=QWidget()
354
+ left_pane.setAcceptDrops(True)
355
+ left_layout=QVBoxLayout(left_pane)
356
+
357
+ self.scene=MoleculeScene(self.data,self)
358
+ self.scene.setSceneRect(-4000,-4000,4000,4000)
359
+ self.scene.setBackgroundBrush(QColor("#FFFFFF"))
360
+
361
+ self.view_2d=ZoomableView(self.scene, self)
362
+ self.view_2d.setRenderHint(QPainter.RenderHint.Antialiasing)
363
+ self.view_2d.setSizePolicy(
364
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
365
+ )
366
+ left_layout.addWidget(self.view_2d, 1)
367
+
368
+ self.view_2d.scale(0.75, 0.75)
369
+
370
+ # --- 左パネルのボタンレイアウト ---
371
+ left_buttons_layout = QHBoxLayout()
372
+ self.cleanup_button = QPushButton("Optimize 2D")
373
+ self.cleanup_button.clicked.connect(self.clean_up_2d_structure)
374
+ left_buttons_layout.addWidget(self.cleanup_button)
375
+
376
+ self.convert_button = QPushButton("Convert 2D to 3D")
377
+ self.convert_button.clicked.connect(self.trigger_conversion)
378
+ # Allow right-click to open a temporary conversion-mode menu
379
+ try:
380
+ self.convert_button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
381
+ self.convert_button.customContextMenuRequested.connect(self.show_convert_menu)
382
+ except Exception:
383
+ pass
384
+ left_buttons_layout.addWidget(self.convert_button)
385
+
386
+ left_layout.addLayout(left_buttons_layout)
387
+ self.splitter.addWidget(left_pane)
388
+
389
+ # --- 右パネルとボタンレイアウト ---
390
+ right_pane = QWidget()
391
+ # 1. 右パネル全体は「垂直」レイアウトにする
392
+ right_layout = QVBoxLayout(right_pane)
393
+ self.plotter = CustomQtInteractor(right_pane, main_window=self, lighting='none')
394
+ self.plotter.setAcceptDrops(False)
395
+ self.plotter.setSizePolicy(
396
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
397
+ )
398
+ self.plotter.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
399
+
400
+ # 2. 垂直レイアウトに3Dビューを追加
401
+ right_layout.addWidget(self.plotter, 1)
402
+ #self.plotter.installEventFilter(self)
403
+ # 3. ボタンをまとめるための「水平」レイアウトを作成
404
+ right_buttons_layout = QHBoxLayout()
405
+
406
+ # 3D最適化ボタン
407
+ self.optimize_3d_button = QPushButton("Optimize 3D")
408
+ self.optimize_3d_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
409
+ self.optimize_3d_button.clicked.connect(self.optimize_3d_structure)
410
+ self.optimize_3d_button.setEnabled(False)
411
+ # 初期状態は_enable_3d_features(False)で統一的に設定
412
+ # Allow right-click to open a temporary optimization-method menu
413
+ try:
414
+ self.optimize_3d_button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
415
+ self.optimize_3d_button.customContextMenuRequested.connect(self.show_optimize_menu)
416
+ except Exception:
417
+ pass
418
+ pass
419
+ right_buttons_layout.addWidget(self.optimize_3d_button)
420
+
421
+ # エクスポートボタン (メニュー付き)
422
+ self.export_button = QToolButton()
423
+ self.export_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
424
+ self.export_button.setText("Export 3D")
425
+ self.export_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
426
+ self.export_button.setEnabled(False) # 初期状態は無効
427
+
428
+ export_menu = QMenu(self)
429
+ export_mol_action = QAction("Export as MOL...", self)
430
+ export_mol_action.triggered.connect(self.save_3d_as_mol)
431
+ export_menu.addAction(export_mol_action)
432
+
433
+ export_xyz_action = QAction("Export as XYZ...", self)
434
+ export_xyz_action.triggered.connect(self.save_as_xyz)
435
+ export_menu.addAction(export_xyz_action)
436
+
437
+ export_png_action = QAction("Export as PNG...", self)
438
+ export_png_action.triggered.connect(self.export_3d_png)
439
+ export_menu.addAction(export_png_action)
440
+
441
+ self.export_button.setMenu(export_menu)
442
+ right_buttons_layout.addWidget(self.export_button)
443
+
444
+ # 4. 水平のボタンレイアウトを、全体の垂直レイアウトに追加
445
+ right_layout.addLayout(right_buttons_layout)
446
+ self.splitter.addWidget(right_pane)
447
+
448
+ # スプリッターのサイズ変更をモニターして、フィードバックを提供
449
+ self.splitter.splitterMoved.connect(self.on_splitter_moved)
450
+
451
+ self.splitter.setSizes([600, 600])
452
+
453
+ # スプリッターハンドルにツールチップを設定
454
+ QTimer.singleShot(100, self.setup_splitter_tooltip)
455
+
456
+ # ステータスバーを左右に分離するための設定
457
+ self.status_bar = self.statusBar()
458
+ self.formula_label = QLabel("") # 右側に表示するラベルを作成
459
+ # 右端に余白を追加して見栄えを調整
460
+ self.formula_label.setStyleSheet("padding-right: 8px;")
461
+ # ラベルを右側に常時表示ウィジェットとして追加
462
+ self.status_bar.addPermanentWidget(self.formula_label)
463
+
464
+ #self.view_2d.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
465
+
466
+ # Top/main toolbar (keep 3D Edit controls on the right end of this toolbar)
467
+ toolbar = QToolBar("Main Toolbar")
468
+ self.addToolBar(toolbar)
469
+ # Keep a reference to the main toolbar for later updates
470
+ self.toolbar = toolbar
471
+
472
+ # Now that toolbar exists, initialize menu bar (which might add toolbar actions from plugins)
473
+ # self.init_menu_bar() - Moved down
474
+
475
+ # Templates toolbar: place it directly below the main toolbar (second row at the top)
476
+ # Use addToolBarBreak to ensure this toolbar appears on the next row under the main toolbar.
477
+ # Some older PyQt/PySide versions may not have addToolBarBreak; fall back silently in that case.
478
+ try:
479
+ # Insert a toolbar break in the Top toolbar area to force the next toolbar onto a new row
480
+ self.addToolBarBreak(Qt.ToolBarArea.TopToolBarArea)
481
+ except Exception:
482
+ # If addToolBarBreak isn't available, continue without raising; placement may still work depending on the platform.
483
+ pass
484
+
485
+ toolbar_bottom = QToolBar("Templates Toolbar")
486
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar_bottom)
487
+ self.toolbar_bottom = toolbar_bottom
488
+
489
+ # Plugin Toolbar (Third Row)
490
+ try:
491
+ self.addToolBarBreak(Qt.ToolBarArea.TopToolBarArea)
492
+ except Exception:
493
+ pass
494
+
495
+ self.plugin_toolbar = QToolBar("Plugin Toolbar")
496
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.plugin_toolbar)
497
+ self.plugin_toolbar.hide()
498
+
499
+ # Initialize menu bar (and populate toolbars) AFTER all toolbars are created
500
+ self.init_menu_bar()
501
+
502
+ self.tool_group = QActionGroup(self)
503
+ self.tool_group.setExclusive(True)
504
+
505
+ actions_data = [
506
+ ("Select", 'select', 'Space'), ("C", 'atom_C', 'c'), ("H", 'atom_H', 'h'), ("B", 'atom_B', 'b'),
507
+ ("N", 'atom_N', 'n'), ("O", 'atom_O', 'o'), ("S", 'atom_S', 's'), ("Si", 'atom_Si', 'Shift+S'), ("P", 'atom_P', 'p'),
508
+ ("F", 'atom_F', 'f'), ("Cl", 'atom_Cl', 'Shift+C'), ("Br", 'atom_Br', 'Shift+B'), ("I", 'atom_I', 'i'),
509
+ ("Other...", 'atom_other', '')
510
+ ]
511
+
512
+ for text, mode, shortcut_text in actions_data:
513
+ if text == "C": toolbar.addSeparator()
514
+
515
+ action = QAction(text, self, checkable=(mode != 'atom_other'))
516
+ if shortcut_text: action.setToolTip(f"{text} ({shortcut_text})")
517
+
518
+ if mode == 'atom_other':
519
+ action.triggered.connect(self.open_periodic_table_dialog)
520
+ self.other_atom_action = action
521
+ else:
522
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
523
+ self.mode_actions[mode] = action
524
+
525
+ toolbar.addAction(action)
526
+ if mode != 'atom_other': self.tool_group.addAction(action)
527
+
528
+ if text == "Select":
529
+ select_action = action
530
+
531
+ toolbar.addSeparator()
532
+
533
+ # --- アイコン前景色を決めるヘルパー(ダーク/ライトモード対応) ---
534
+ # Use module-level detector `detect_system_dark_mode()` so tests and other
535
+ # modules can reuse the logic.
536
+
537
+
538
+ def _icon_foreground_color():
539
+ """Return a QColor for icon foreground.
540
+
541
+ NOTE: choose icon foreground to contrast against the background
542
+ (i.e., white on dark backgrounds, black on light backgrounds). This
543
+ matches common conventions. Priority: explicit setting in
544
+ 'icon_foreground' -> OS theme preference -> configured 3D
545
+ theme preference -> configured 3D background -> application palette.
546
+ """
547
+ try:
548
+ fg_hex = self.settings.get('icon_foreground')
549
+ if fg_hex:
550
+ c = QColor(fg_hex)
551
+ if c.isValid():
552
+ return c
553
+ except Exception:
554
+ pass
555
+
556
+ # 1) Prefer the system/OS dark-mode preference if available.
557
+ try:
558
+ os_pref = detect_system_dark_mode()
559
+ # Standard mapping: dark -> white, light -> black
560
+ if os_pref is not None:
561
+ return QColor('#FFFFFF') if os_pref else QColor('#000000')
562
+ except Exception:
563
+ pass
564
+
565
+ try:
566
+ # Keep background_color as a fallback: if system preference isn't
567
+ # available we'll use the configured 3D view background from settings.
568
+ bg_hex = self.settings.get('background_color')
569
+ if bg_hex:
570
+ bg = QColor(bg_hex)
571
+ if bg.isValid():
572
+ lum = 0.2126 * bg.redF() + 0.7152 * bg.greenF() + 0.0722 * bg.blueF()
573
+ # Return white on dark (lum<0.5), black on light
574
+ return QColor('#FFFFFF') if lum < 0.5 else QColor('#000000')
575
+ except Exception:
576
+ pass
577
+
578
+ try:
579
+ pal = QApplication.palette()
580
+ # palette.window() returns a QBrush; call color()
581
+ window_bg = pal.window().color()
582
+ lum = 0.2126 * window_bg.redF() + 0.7152 * window_bg.greenF() + 0.0722 * window_bg.blueF()
583
+ # Palette-based mapping: white on dark palette background
584
+ return QColor('#FFFFFF') if lum < 0.5 else QColor('#000000')
585
+ except Exception:
586
+ return QColor('#000000')
587
+
588
+ # --- 結合ボタンのアイコンを生成するヘルパー関数 ---
589
+ def create_bond_icon(bond_type, size=32):
590
+ fg = _icon_foreground_color()
591
+ pixmap = QPixmap(size, size)
592
+ pixmap.fill(Qt.GlobalColor.transparent)
593
+ painter = QPainter(pixmap)
594
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
595
+
596
+ p1 = QPointF(6, size / 2)
597
+ p2 = QPointF(size - 6, size / 2)
598
+ line = QLineF(p1, p2)
599
+
600
+ pen = QPen(fg, 2)
601
+ painter.setPen(pen)
602
+ painter.setBrush(QBrush(fg))
603
+
604
+ if bond_type == 'single':
605
+ painter.drawLine(line)
606
+ elif bond_type == 'double':
607
+ v = line.unitVector().normalVector()
608
+ offset = QPointF(v.dx(), v.dy()) * 2.5
609
+ painter.drawLine(line.translated(offset))
610
+ painter.drawLine(line.translated(-offset))
611
+ elif bond_type == 'triple':
612
+ v = line.unitVector().normalVector()
613
+ offset = QPointF(v.dx(), v.dy()) * 3.0
614
+ painter.drawLine(line)
615
+ painter.drawLine(line.translated(offset))
616
+ painter.drawLine(line.translated(-offset))
617
+ elif bond_type == 'wedge':
618
+ vec = line.unitVector()
619
+ normal = vec.normalVector()
620
+ offset = QPointF(normal.dx(), normal.dy()) * 5.0
621
+ poly = QPolygonF([p1, p2 + offset, p2 - offset])
622
+ painter.drawPolygon(poly)
623
+ elif bond_type == 'dash':
624
+ vec = line.unitVector()
625
+ normal = vec.normalVector()
626
+
627
+ num_dashes = NUM_DASHES
628
+ for i in range(num_dashes + 1):
629
+ t = i / num_dashes
630
+ start_pt = p1 * (1 - t) + p2 * t
631
+ width = 10 * t
632
+ offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
633
+ painter.setPen(QPen(fg, 1.5))
634
+ painter.drawLine(start_pt - offset, start_pt + offset)
635
+
636
+ elif bond_type == 'ez_toggle':
637
+ # アイコン下部に二重結合を描画
638
+ p1 = QPointF(6, size * 0.75)
639
+ p2 = QPointF(size - 6, size * 0.75)
640
+ line = QLineF(p1, p2)
641
+ v = line.unitVector().normalVector()
642
+ offset = QPointF(v.dx(), v.dy()) * 2.0
643
+ painter.setPen(QPen(fg, 2))
644
+ painter.drawLine(line.translated(offset))
645
+ painter.drawLine(line.translated(-offset))
646
+ # 上部に "Z⇌E" のテキストを描画
647
+ painter.setPen(QPen(fg, 1))
648
+ font = painter.font()
649
+ font.setPointSize(10)
650
+ font.setBold(True)
651
+ painter.setFont(font)
652
+ text_rect = QRectF(0, 0, size, size * 0.6)
653
+ # U+21CC は右向きと左向きのハープーンが重なった記号 (⇌)
654
+ painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, "Z⇌E")
655
+
656
+ painter.end()
657
+ return QIcon(pixmap)
658
+
659
+ # --- 結合ボタンをツールバーに追加 ---
660
+ bond_actions_data = [
661
+ ("Single Bond", 'bond_1_0', '1', 'single'),
662
+ ("Double Bond", 'bond_2_0', '2', 'double'),
663
+ ("Triple Bond", 'bond_3_0', '3', 'triple'),
664
+ ("Wedge Bond", 'bond_1_1', 'W', 'wedge'),
665
+ ("Dash Bond", 'bond_1_2', 'D', 'dash'),
666
+ ("Toggle E/Z", 'bond_2_5', 'E/Z', 'ez_toggle'),
667
+ ]
668
+
669
+ for text, mode, shortcut_text, icon_type in bond_actions_data:
670
+ action = QAction(self)
671
+ action.setIcon(create_bond_icon(icon_type))
672
+ action.setToolTip(f"{text} ({shortcut_text})")
673
+ action.setCheckable(True)
674
+ action.triggered.connect(lambda checked, m=mode: self.set_mode(m))
675
+ self.mode_actions[mode] = action
676
+ toolbar.addAction(action)
677
+ self.tool_group.addAction(action)
678
+
679
+ toolbar.addSeparator()
680
+
681
+ charge_plus_action = QAction("+ Charge", self, checkable=True)
682
+ charge_plus_action.setToolTip("Increase Atom Charge (+)")
683
+ charge_plus_action.triggered.connect(lambda c, m='charge_plus': self.set_mode(m))
684
+ self.mode_actions['charge_plus'] = charge_plus_action
685
+ toolbar.addAction(charge_plus_action)
686
+ self.tool_group.addAction(charge_plus_action)
687
+
688
+ charge_minus_action = QAction("- Charge", self, checkable=True)
689
+ charge_minus_action.setToolTip("Decrease Atom Charge (-)")
690
+ charge_minus_action.triggered.connect(lambda c, m='charge_minus': self.set_mode(m))
691
+ self.mode_actions['charge_minus'] = charge_minus_action
692
+ toolbar.addAction(charge_minus_action)
693
+ self.tool_group.addAction(charge_minus_action)
694
+
695
+ radical_action = QAction("Radical", self, checkable=True)
696
+ radical_action.setToolTip("Toggle Radical (0/1/2) (.)")
697
+ radical_action.triggered.connect(lambda c, m='radical': self.set_mode(m))
698
+ self.mode_actions['radical'] = radical_action
699
+ toolbar.addAction(radical_action)
700
+ self.tool_group.addAction(radical_action)
701
+
702
+ # We will show template controls in the bottom toolbar to improve layout.
703
+ # Add a small label to the bottom toolbar instead of the main toolbar.
704
+ toolbar_bottom.addWidget(QLabel(" Templates:"))
705
+
706
+ # --- アイコンを生成するヘルパー関数 ---
707
+ def create_template_icon(n, is_benzene=False):
708
+ size = 32
709
+ fg = _icon_foreground_color()
710
+ pixmap = QPixmap(size, size)
711
+ pixmap.fill(Qt.GlobalColor.transparent)
712
+ painter = QPainter(pixmap)
713
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
714
+ painter.setPen(QPen(fg, 2))
715
+
716
+ center = QPointF(size / 2, size / 2)
717
+ radius = size / 2 - 4 # アイコンの余白
718
+
719
+ points = []
720
+ angle_step = 2 * math.pi / n
721
+ # ポリゴンが直立するように開始角度を調整
722
+ start_angle = -math.pi / 2 if n % 2 != 0 else -math.pi / 2 - angle_step / 2
723
+
724
+ for i in range(n):
725
+ angle = start_angle + i * angle_step
726
+ x = center.x() + radius * math.cos(angle)
727
+ y = center.y() + radius * math.sin(angle)
728
+ points.append(QPointF(x, y))
729
+
730
+ painter.drawPolygon(QPolygonF(points))
731
+
732
+ if is_benzene:
733
+ painter.drawEllipse(center, radius * 0.6, radius * 0.6)
734
+
735
+ if n in [7, 8, 9]:
736
+ font = QFont("Arial", 10, QFont.Weight.Bold)
737
+ painter.setFont(font)
738
+ painter.setPen(QPen(fg, 1))
739
+ painter.drawText(QRectF(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, str(n))
740
+
741
+ painter.end()
742
+ return QIcon(pixmap)
743
+
744
+ # --- ヘルパー関数を使ってアイコン付きボタンを作成 ---
745
+ templates = [("Benzene", "template_benzene", 6)] + [(f"{i}-Ring", f"template_{i}", i) for i in range(3, 10)]
746
+ for text, mode, n in templates:
747
+ action = QAction(self) # テキストなしでアクションを作成
748
+ action.setCheckable(True)
749
+
750
+ is_benzene = (text == "Benzene")
751
+ icon = create_template_icon(n, is_benzene=is_benzene)
752
+ action.setIcon(icon) # アイコンを設定
753
+
754
+ if text == "Benzene":
755
+ action.setToolTip(f"{text} Template (4)")
756
+ else:
757
+ action.setToolTip(f"{text} Template")
758
+
759
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
760
+ self.mode_actions[mode] = action
761
+ # Add template actions to the bottom toolbar so templates are on the second line
762
+ toolbar_bottom.addAction(action)
763
+ self.tool_group.addAction(action)
764
+
765
+ # Add USER button for user templates (placed in bottom toolbar)
766
+ user_template_action = QAction("USER", self)
767
+ user_template_action.setCheckable(True)
768
+ user_template_action.setToolTip("Open User Templates Dialog")
769
+ user_template_action.triggered.connect(self.open_template_dialog_and_activate)
770
+ self.mode_actions['template_user'] = user_template_action
771
+ toolbar_bottom.addAction(user_template_action)
772
+ self.tool_group.addAction(user_template_action)
773
+
774
+ # 初期モードを'select'から'atom_C'(炭素原子描画モード)に変更
775
+ self.set_mode('atom_C')
776
+ # 対応するツールバーの'C'ボタンを選択状態にする
777
+ if 'atom_C' in self.mode_actions:
778
+ self.mode_actions['atom_C'].setChecked(True)
779
+
780
+ # スペーサーを追加して、次のウィジェットを右端に配置する (keep on top toolbar)
781
+ spacer = QWidget()
782
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
783
+ toolbar.addWidget(spacer)
784
+
785
+ # 測定機能ボタンを追加("3D Select"に変更)
786
+ self.measurement_action = QAction("3D Select", self, checkable=True)
787
+ self.measurement_action.setToolTip("Enable distance, angle, and dihedral measurement in 3D view")
788
+ # 初期状態でも有効にする
789
+ self.measurement_action.triggered.connect(self.toggle_measurement_mode)
790
+ toolbar.addAction(self.measurement_action)
791
+
792
+ self.edit_3d_action = QAction("3D Drag", self, checkable=True)
793
+ self.edit_3d_action.setToolTip("Toggle 3D atom dragging mode (Hold Alt for temporary mode)")
794
+ # 初期状態でも有効にする
795
+ self.edit_3d_action.toggled.connect(self.toggle_3d_edit_mode)
796
+ toolbar.addAction(self.edit_3d_action)
797
+
798
+ # 3Dスタイル変更ボタンとメニューを作成
799
+
800
+ self.style_button = QToolButton()
801
+ self.style_button.setText("3D Style")
802
+ self.style_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
803
+ toolbar.addWidget(self.style_button)
804
+
805
+ style_menu = QMenu(self)
806
+ self.style_button.setMenu(style_menu)
807
+
808
+ style_group = QActionGroup(self)
809
+ style_group.setExclusive(True)
810
+
811
+ # Ball & Stick アクション
812
+ bs_action = QAction("Ball & Stick", self, checkable=True)
813
+ bs_action.setChecked(True)
814
+ bs_action.triggered.connect(lambda: self.set_3d_style('ball_and_stick'))
815
+ style_menu.addAction(bs_action)
816
+ style_group.addAction(bs_action)
817
+
818
+ # CPK アクション
819
+ cpk_action = QAction("CPK (Space-filling)", self, checkable=True)
820
+ cpk_action.triggered.connect(lambda: self.set_3d_style('cpk'))
821
+ style_menu.addAction(cpk_action)
822
+ style_group.addAction(cpk_action)
823
+
824
+ # Wireframe アクション
825
+ wireframe_action = QAction("Wireframe", self, checkable=True)
826
+ wireframe_action.triggered.connect(lambda: self.set_3d_style('wireframe'))
827
+ style_menu.addAction(wireframe_action)
828
+ style_group.addAction(wireframe_action)
829
+
830
+ # Stick アクション
831
+ stick_action = QAction("Stick", self, checkable=True)
832
+ stick_action.triggered.connect(lambda: self.set_3d_style('stick'))
833
+ style_menu.addAction(stick_action)
834
+ style_group.addAction(stick_action)
835
+
836
+ quit_shortcut = QShortcut(QKeySequence("Ctrl+Q"), self)
837
+ quit_shortcut.activated.connect(self.close)
838
+
839
+ self.view_2d.setFocus()
840
+
841
+
842
+
843
+ def init_menu_bar(self):
844
+ menu_bar = self.menuBar()
845
+
846
+ file_menu = menu_bar.addMenu("&File")
847
+
848
+ # === プロジェクト操作 ===
849
+ new_action = QAction("&New", self)
850
+ new_action.setShortcut("Ctrl+N")
851
+ new_action.triggered.connect(self.clear_all)
852
+ file_menu.addAction(new_action)
853
+
854
+ load_project_action = QAction("&Open Project...", self)
855
+ load_project_action.setShortcut("Ctrl+O")
856
+ load_project_action.triggered.connect(self.open_project_file)
857
+ file_menu.addAction(load_project_action)
858
+
859
+ save_action = QAction("&Save Project", self)
860
+ save_action.setShortcut("Ctrl+S")
861
+ save_action.triggered.connect(self.save_project)
862
+ file_menu.addAction(save_action)
863
+
864
+ save_as_action = QAction("Save Project &As...", self)
865
+ save_as_action.setShortcut("Ctrl+Shift+S")
866
+ save_as_action.triggered.connect(self.save_project_as)
867
+ file_menu.addAction(save_as_action)
868
+
869
+ save_template_action = QAction("Save 2D as Template...", self)
870
+ save_template_action.triggered.connect(self.save_2d_as_template)
871
+ file_menu.addAction(save_template_action)
872
+
873
+ file_menu.addSeparator()
874
+
875
+
876
+ # === インポート ===
877
+ self.import_menu = file_menu.addMenu("Import")
878
+
879
+ load_mol_action = QAction("MOL/SDF File...", self)
880
+ load_mol_action.triggered.connect(self.load_mol_file)
881
+ self.import_menu.addAction(load_mol_action)
882
+
883
+ import_smiles_action = QAction("SMILES...", self)
884
+ import_smiles_action.triggered.connect(self.import_smiles_dialog)
885
+ self.import_menu.addAction(import_smiles_action)
886
+
887
+ import_inchi_action = QAction("InChI...", self)
888
+ import_inchi_action.triggered.connect(self.import_inchi_dialog)
889
+ self.import_menu.addAction(import_inchi_action)
890
+
891
+ self.import_menu.addSeparator()
892
+
893
+ load_3d_mol_action = QAction("3D MOL/SDF (3D View Only)...", self)
894
+ load_3d_mol_action.triggered.connect(self.load_mol_file_for_3d_viewing)
895
+ self.import_menu.addAction(load_3d_mol_action)
896
+
897
+ load_3d_xyz_action = QAction("3D XYZ (3D View Only)...", self)
898
+ load_3d_xyz_action.triggered.connect(self.load_xyz_for_3d_viewing)
899
+ self.import_menu.addAction(load_3d_xyz_action)
900
+
901
+ # === エクスポート ===
902
+ export_menu = file_menu.addMenu("Export")
903
+
904
+ # プロジェクト形式エクスポート
905
+ export_pmeraw_action = QAction("PME Raw Format...", self)
906
+ export_pmeraw_action.triggered.connect(self.save_raw_data)
907
+ export_menu.addAction(export_pmeraw_action)
908
+
909
+ export_menu.addSeparator()
910
+
911
+ # 2D エクスポート
912
+ export_2d_menu = export_menu.addMenu("2D Formats")
913
+ save_mol_action = QAction("MOL File...", self)
914
+ save_mol_action.triggered.connect(self.save_as_mol)
915
+ export_2d_menu.addAction(save_mol_action)
916
+
917
+ export_2d_png_action = QAction("PNG Image...", self)
918
+ export_2d_png_action.triggered.connect(self.export_2d_png)
919
+ export_2d_menu.addAction(export_2d_png_action)
920
+
921
+ # 3D エクスポート
922
+ export_3d_menu = export_menu.addMenu("3D Formats")
923
+ save_3d_mol_action = QAction("MOL File...", self)
924
+ save_3d_mol_action.triggered.connect(self.save_3d_as_mol)
925
+ export_3d_menu.addAction(save_3d_mol_action)
926
+
927
+ save_xyz_action = QAction("XYZ File...", self)
928
+ save_xyz_action.triggered.connect(self.save_as_xyz)
929
+ export_3d_menu.addAction(save_xyz_action)
930
+
931
+ export_3d_png_action = QAction("PNG Image...", self)
932
+ export_3d_png_action.triggered.connect(self.export_3d_png)
933
+ export_3d_menu.addAction(export_3d_png_action)
934
+
935
+ export_3d_menu.addSeparator()
936
+
937
+ export_stl_action = QAction("STL File...", self)
938
+ export_stl_action.triggered.connect(self.export_stl)
939
+ export_3d_menu.addAction(export_stl_action)
940
+
941
+ export_obj_action = QAction("OBJ/MTL (with colors)...", self)
942
+ export_obj_action.triggered.connect(self.export_obj_mtl)
943
+ export_3d_menu.addAction(export_obj_action)
944
+
945
+ file_menu.addSeparator()
946
+ quit_action = QAction("Quit", self)
947
+ quit_action.triggered.connect(self.close)
948
+ file_menu.addAction(quit_action)
949
+
950
+ edit_menu = menu_bar.addMenu("&Edit")
951
+ self.undo_action = QAction("Undo", self); self.undo_action.setShortcut(QKeySequence.StandardKey.Undo)
952
+ self.undo_action.triggered.connect(self.undo); edit_menu.addAction(self.undo_action)
953
+
954
+ self.redo_action = QAction("Redo", self); self.redo_action.setShortcut(QKeySequence.StandardKey.Redo)
955
+ self.redo_action.triggered.connect(self.redo); edit_menu.addAction(self.redo_action)
956
+
957
+ edit_menu.addSeparator()
958
+
959
+ self.cut_action = QAction("Cut", self)
960
+ self.cut_action.setShortcut(QKeySequence.StandardKey.Cut)
961
+ self.cut_action.triggered.connect(self.cut_selection)
962
+ edit_menu.addAction(self.cut_action)
963
+
964
+ self.copy_action = QAction("Copy", self)
965
+ self.copy_action.setShortcut(QKeySequence.StandardKey.Copy)
966
+ self.copy_action.triggered.connect(self.copy_selection)
967
+ edit_menu.addAction(self.copy_action)
968
+
969
+ self.paste_action = QAction("Paste", self)
970
+ self.paste_action.setShortcut(QKeySequence.StandardKey.Paste)
971
+ self.paste_action.triggered.connect(self.paste_from_clipboard)
972
+ edit_menu.addAction(self.paste_action)
973
+
974
+ edit_menu.addSeparator()
975
+
976
+ add_hydrogen_action = QAction("Add Hydrogens", self)
977
+ add_hydrogen_action.setToolTip("Add explicit hydrogens based on RDKit implicit counts")
978
+ add_hydrogen_action.triggered.connect(self.add_hydrogen_atoms)
979
+ edit_menu.addAction(add_hydrogen_action)
980
+
981
+ remove_hydrogen_action = QAction("Remove Hydrogens", self)
982
+ remove_hydrogen_action.triggered.connect(self.remove_hydrogen_atoms)
983
+ edit_menu.addAction(remove_hydrogen_action)
984
+
985
+ edit_menu.addSeparator()
986
+
987
+ optimize_2d_action = QAction("Optimize 2D", self)
988
+ optimize_2d_action.setShortcut(QKeySequence("Ctrl+J"))
989
+ optimize_2d_action.triggered.connect(self.clean_up_2d_structure)
990
+ edit_menu.addAction(optimize_2d_action)
991
+
992
+ convert_3d_action = QAction("Convert 2D to 3D", self)
993
+ convert_3d_action.setShortcut(QKeySequence("Ctrl+K"))
994
+ convert_3d_action.triggered.connect(self.trigger_conversion)
995
+ edit_menu.addAction(convert_3d_action)
996
+
997
+ optimize_3d_action = QAction("Optimize 3D", self)
998
+ optimize_3d_action.setShortcut(QKeySequence("Ctrl+L"))
999
+ optimize_3d_action.triggered.connect(self.optimize_3d_structure)
1000
+ edit_menu.addAction(optimize_3d_action)
1001
+
1002
+ # Note: 3D Optimization Settings moved to Settings -> "3D Optimization Settings"
1003
+ # to avoid duplicating the same submenu in both Edit and Settings.
1004
+
1005
+ # Note: Open Babel-based optimization menu entries were intentionally
1006
+ # removed above. Open Babel (pybel) is still available for conversion
1007
+ # fallback elsewhere in the code, so we don't disable menu items here.
1008
+
1009
+ edit_menu.addSeparator()
1010
+
1011
+ select_all_action = QAction("Select All", self); select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
1012
+ select_all_action.triggered.connect(self.select_all); edit_menu.addAction(select_all_action)
1013
+
1014
+ clear_all_action = QAction("Clear All", self)
1015
+ clear_all_action.setShortcut(QKeySequence("Ctrl+Shift+C"))
1016
+ clear_all_action.triggered.connect(self.clear_all); edit_menu.addAction(clear_all_action)
1017
+
1018
+ view_menu = menu_bar.addMenu("&View")
1019
+
1020
+ zoom_in_action = QAction("Zoom In", self)
1021
+ zoom_in_action.setShortcut(QKeySequence.StandardKey.ZoomIn) # Ctrl +
1022
+ zoom_in_action.triggered.connect(self.zoom_in)
1023
+ view_menu.addAction(zoom_in_action)
1024
+
1025
+ zoom_out_action = QAction("Zoom Out", self)
1026
+ zoom_out_action.setShortcut(QKeySequence.StandardKey.ZoomOut) # Ctrl -
1027
+ zoom_out_action.triggered.connect(self.zoom_out)
1028
+ view_menu.addAction(zoom_out_action)
1029
+
1030
+ reset_zoom_action = QAction("Reset Zoom", self)
1031
+ reset_zoom_action.setShortcut(QKeySequence("Ctrl+0"))
1032
+ reset_zoom_action.triggered.connect(self.reset_zoom)
1033
+ view_menu.addAction(reset_zoom_action)
1034
+
1035
+ fit_action = QAction("Fit to View", self)
1036
+ fit_action.setShortcut(QKeySequence("Ctrl+9"))
1037
+ fit_action.triggered.connect(self.fit_to_view)
1038
+ view_menu.addAction(fit_action)
1039
+
1040
+ view_menu.addSeparator()
1041
+
1042
+ reset_3d_view_action = QAction("Reset 3D View", self)
1043
+ reset_3d_view_action.triggered.connect(lambda: self.plotter.reset_camera() if hasattr(self, 'plotter') else None)
1044
+ reset_3d_view_action.setShortcut(QKeySequence("Ctrl+R"))
1045
+ view_menu.addAction(reset_3d_view_action)
1046
+
1047
+ view_menu.addSeparator()
1048
+
1049
+ # Panel Layout submenu
1050
+ layout_menu = view_menu.addMenu("Panel Layout")
1051
+
1052
+ equal_panels_action = QAction("Equal Panels (50:50)", self)
1053
+ equal_panels_action.setShortcut(QKeySequence("Ctrl+1"))
1054
+ equal_panels_action.triggered.connect(lambda: self.set_panel_layout(50, 50))
1055
+ layout_menu.addAction(equal_panels_action)
1056
+
1057
+ layout_2d_focus_action = QAction("2D Focus (70:30)", self)
1058
+ layout_2d_focus_action.setShortcut(QKeySequence("Ctrl+2"))
1059
+ layout_2d_focus_action.triggered.connect(lambda: self.set_panel_layout(70, 30))
1060
+ layout_menu.addAction(layout_2d_focus_action)
1061
+
1062
+ layout_3d_focus_action = QAction("3D Focus (30:70)", self)
1063
+ layout_3d_focus_action.setShortcut(QKeySequence("Ctrl+3"))
1064
+ layout_3d_focus_action.triggered.connect(lambda: self.set_panel_layout(30, 70))
1065
+ layout_menu.addAction(layout_3d_focus_action)
1066
+
1067
+ layout_menu.addSeparator()
1068
+
1069
+ toggle_2d_panel_action = QAction("Toggle 2D Panel", self)
1070
+ toggle_2d_panel_action.setShortcut(QKeySequence("Ctrl+H"))
1071
+ toggle_2d_panel_action.triggered.connect(self.toggle_2d_panel)
1072
+ layout_menu.addAction(toggle_2d_panel_action)
1073
+
1074
+ view_menu.addSeparator()
1075
+
1076
+ self.toggle_chiral_action = QAction("Show Chiral Labels", self, checkable=True)
1077
+ self.toggle_chiral_action.setChecked(self.show_chiral_labels)
1078
+ self.toggle_chiral_action.triggered.connect(self.toggle_chiral_labels_display)
1079
+ view_menu.addAction(self.toggle_chiral_action)
1080
+
1081
+ view_menu.addSeparator()
1082
+
1083
+ # 3D Atom Info submenu
1084
+ atom_info_menu = view_menu.addMenu("3D Atom Info Display")
1085
+
1086
+ self.show_atom_id_action = QAction("Show Original ID / Index", self, checkable=True)
1087
+ self.show_atom_id_action.triggered.connect(lambda: self.toggle_atom_info_display('id'))
1088
+ atom_info_menu.addAction(self.show_atom_id_action)
1089
+
1090
+ self.show_rdkit_id_action = QAction("Show RDKit Index", self, checkable=True)
1091
+ self.show_rdkit_id_action.triggered.connect(lambda: self.toggle_atom_info_display('rdkit_id'))
1092
+ atom_info_menu.addAction(self.show_rdkit_id_action)
1093
+
1094
+ self.show_atom_coords_action = QAction("Show Coordinates (X,Y,Z)", self, checkable=True)
1095
+ self.show_atom_coords_action.triggered.connect(lambda: self.toggle_atom_info_display('coords'))
1096
+ atom_info_menu.addAction(self.show_atom_coords_action)
1097
+
1098
+ self.show_atom_symbol_action = QAction("Show Element Symbol", self, checkable=True)
1099
+ self.show_atom_symbol_action.triggered.connect(lambda: self.toggle_atom_info_display('symbol'))
1100
+ atom_info_menu.addAction(self.show_atom_symbol_action)
1101
+
1102
+ analysis_menu = menu_bar.addMenu("&Analysis")
1103
+ self.analysis_action = QAction("Show Analysis...", self)
1104
+ self.analysis_action.triggered.connect(self.open_analysis_window)
1105
+ self.analysis_action.setEnabled(False)
1106
+ analysis_menu.addAction(self.analysis_action)
1107
+
1108
+ # 3D Edit menu
1109
+ edit_3d_menu = menu_bar.addMenu("3D &Edit")
1110
+
1111
+ # Translation action
1112
+ translation_action = QAction("Translation...", self)
1113
+ translation_action.triggered.connect(self.open_translation_dialog)
1114
+ translation_action.setEnabled(False)
1115
+ edit_3d_menu.addAction(translation_action)
1116
+ self.translation_action = translation_action
1117
+
1118
+ # Move Group action
1119
+ move_group_action = QAction("Move Group...", self)
1120
+ move_group_action.triggered.connect(self.open_move_group_dialog)
1121
+ move_group_action.setEnabled(False)
1122
+ edit_3d_menu.addAction(move_group_action)
1123
+ self.move_group_action = move_group_action
1124
+
1125
+ edit_3d_menu.addSeparator()
1126
+
1127
+ # Alignment submenu (統合)
1128
+ align_menu = edit_3d_menu.addMenu("Align to")
1129
+ align_menu.setEnabled(False)
1130
+ self.align_menu = align_menu
1131
+
1132
+ # Axis alignment submenu
1133
+ axis_align_menu = align_menu.addMenu("Axis")
1134
+
1135
+ align_x_action = QAction("X-axis", self)
1136
+ align_x_action.triggered.connect(lambda: self.open_alignment_dialog('x'))
1137
+ align_x_action.setEnabled(False)
1138
+ axis_align_menu.addAction(align_x_action)
1139
+ self.align_x_action = align_x_action
1140
+
1141
+ align_y_action = QAction("Y-axis", self)
1142
+ align_y_action.triggered.connect(lambda: self.open_alignment_dialog('y'))
1143
+ align_y_action.setEnabled(False)
1144
+ axis_align_menu.addAction(align_y_action)
1145
+ self.align_y_action = align_y_action
1146
+
1147
+ align_z_action = QAction("Z-axis", self)
1148
+ align_z_action.triggered.connect(lambda: self.open_alignment_dialog('z'))
1149
+ align_z_action.setEnabled(False)
1150
+ axis_align_menu.addAction(align_z_action)
1151
+ self.align_z_action = align_z_action
1152
+
1153
+ # Plane alignment submenu (旧align)
1154
+ plane_align_menu = align_menu.addMenu("Plane")
1155
+
1156
+ alignplane_xy_action = QAction("XY-plane", self)
1157
+ alignplane_xy_action.triggered.connect(lambda: self.open_align_plane_dialog('xy'))
1158
+ alignplane_xy_action.setEnabled(False)
1159
+ plane_align_menu.addAction(alignplane_xy_action)
1160
+ self.alignplane_xy_action = alignplane_xy_action
1161
+
1162
+ alignplane_xz_action = QAction("XZ-plane", self)
1163
+ alignplane_xz_action.triggered.connect(lambda: self.open_align_plane_dialog('xz'))
1164
+ alignplane_xz_action.setEnabled(False)
1165
+ plane_align_menu.addAction(alignplane_xz_action)
1166
+ self.alignplane_xz_action = alignplane_xz_action
1167
+
1168
+ alignplane_yz_action = QAction("YZ-plane", self)
1169
+ alignplane_yz_action.triggered.connect(lambda: self.open_align_plane_dialog('yz'))
1170
+ alignplane_yz_action.setEnabled(False)
1171
+ plane_align_menu.addAction(alignplane_yz_action)
1172
+ self.alignplane_yz_action = alignplane_yz_action
1173
+
1174
+ edit_3d_menu.addSeparator()
1175
+
1176
+ # Mirror action
1177
+ mirror_action = QAction("Mirror...", self)
1178
+ mirror_action.triggered.connect(self.open_mirror_dialog)
1179
+ mirror_action.setEnabled(False)
1180
+ edit_3d_menu.addAction(mirror_action)
1181
+ self.mirror_action = mirror_action
1182
+
1183
+ edit_3d_menu.addSeparator()
1184
+
1185
+ # Planarize selection (best-fit plane)
1186
+ planarize_action = QAction("Planarize...", self)
1187
+ planarize_action.triggered.connect(lambda: self.open_planarize_dialog(None))
1188
+ planarize_action.setEnabled(False)
1189
+ edit_3d_menu.addAction(planarize_action)
1190
+ self.planarize_action = planarize_action
1191
+
1192
+ edit_3d_menu.addSeparator()
1193
+
1194
+ # Bond length conversion
1195
+ bond_length_action = QAction("Adjust Bond Length...", self)
1196
+ bond_length_action.triggered.connect(self.open_bond_length_dialog)
1197
+ bond_length_action.setEnabled(False)
1198
+ edit_3d_menu.addAction(bond_length_action)
1199
+ self.bond_length_action = bond_length_action
1200
+
1201
+ # Angle conversion
1202
+ angle_action = QAction("Adjust Angle...", self)
1203
+ angle_action.triggered.connect(self.open_angle_dialog)
1204
+ angle_action.setEnabled(False)
1205
+ edit_3d_menu.addAction(angle_action)
1206
+ self.angle_action = angle_action
1207
+
1208
+ # Dihedral angle conversion
1209
+ dihedral_action = QAction("Adjust Dihedral Angle...", self)
1210
+ dihedral_action.triggered.connect(self.open_dihedral_dialog)
1211
+ dihedral_action.setEnabled(False)
1212
+ edit_3d_menu.addAction(dihedral_action)
1213
+ self.dihedral_action = dihedral_action
1214
+
1215
+ edit_3d_menu.addSeparator()
1216
+
1217
+ # Constrained Optimization action
1218
+ constrained_opt_action = QAction("Constrained Optimization...", self)
1219
+ constrained_opt_action.triggered.connect(self.open_constrained_optimization_dialog)
1220
+ constrained_opt_action.setEnabled(False) # 3Dモデルロード時に有効化
1221
+ edit_3d_menu.addAction(constrained_opt_action)
1222
+ self.constrained_opt_action = constrained_opt_action
1223
+
1224
+ # Plugin menu
1225
+ plugin_menu = menu_bar.addMenu("&Plugin")
1226
+
1227
+ # Only keep the Manager action, moving others to the Manager Window
1228
+ manage_plugins_action = QAction("Plugin Manager...", self)
1229
+ def show_plugin_manager():
1230
+ from .plugin_manager_window import PluginManagerWindow
1231
+ dlg = PluginManagerWindow(self.plugin_manager, self)
1232
+ dlg.exec()
1233
+ self.update_plugin_menu(plugin_menu) # Refresh after closing
1234
+ manage_plugins_action.triggered.connect(show_plugin_manager)
1235
+ plugin_menu.addAction(manage_plugins_action)
1236
+
1237
+
1238
+
1239
+
1240
+ plugin_menu.addSeparator()
1241
+
1242
+ # Initial population of plugins
1243
+ self.update_plugin_menu(plugin_menu)
1244
+
1245
+ settings_menu = menu_bar.addMenu("&Settings")
1246
+ # 1) 3D View settings (existing)
1247
+ view_settings_action = QAction("3D View Settings...", self)
1248
+ view_settings_action.triggered.connect(self.open_settings_dialog)
1249
+ settings_menu.addAction(view_settings_action)
1250
+
1251
+ # Color settings (CPK/Bond) — keep with other settings
1252
+ color_action = QAction("CPK Colors...", self)
1253
+ color_action.triggered.connect(lambda: ColorSettingsDialog(self.settings, parent=self).exec_())
1254
+ settings_menu.addAction(color_action)
1255
+
1256
+ # 2) 3D Conversion settings — submenu with radio/check actions
1257
+ conversion_menu = settings_menu.addMenu("3D Conversion")
1258
+ conv_group = QActionGroup(self)
1259
+ conv_group.setExclusive(True)
1260
+ # helper to set conversion mode and persist
1261
+ def _set_conv_mode(mode):
1262
+ try:
1263
+ self.settings['3d_conversion_mode'] = mode
1264
+ # defer disk write
1265
+ try:
1266
+ self.settings_dirty = True
1267
+ except Exception:
1268
+ pass
1269
+ self.statusBar().showMessage(f"3D conversion mode set to: {mode}")
1270
+ except Exception:
1271
+ pass
1272
+
1273
+ conv_options = [
1274
+ ("RDKit -> Open Babel (fallback)", 'fallback'),
1275
+ ("RDKit only", 'rdkit'),
1276
+ ("Open Babel only", 'obabel'),
1277
+ ("Direct (use 2D coords + add H)", 'direct')
1278
+ ]
1279
+ self.conv_actions = {}
1280
+ for label, key in conv_options:
1281
+ a = QAction(label, self)
1282
+ a.setCheckable(True)
1283
+ # If Open Babel isn't available, disable the Open Babel-only option
1284
+ # and also disable the fallback option since it depends on Open Babel.
1285
+ if not OBABEL_AVAILABLE:
1286
+ if key == 'obabel' or key == 'fallback':
1287
+ a.setEnabled(False)
1288
+ a.triggered.connect(lambda checked, m=key: _set_conv_mode(m))
1289
+ conversion_menu.addAction(a)
1290
+ conv_group.addAction(a)
1291
+ self.conv_actions[key] = a
1292
+
1293
+ # Initialize checked state from settings (fallback default)
1294
+ # Determine saved conversion mode. If Open Babel is not available,
1295
+ # prefer 'rdkit' as the default rather than 'fallback'. Also ensure
1296
+ # the settings reflect the actual enabled choice.
1297
+ try:
1298
+ default_mode = 'rdkit' if not OBABEL_AVAILABLE else 'fallback'
1299
+ saved_conv = self.settings.get('3d_conversion_mode', default_mode)
1300
+ except Exception:
1301
+ saved_conv = 'rdkit' if not OBABEL_AVAILABLE else 'fallback'
1302
+
1303
+ # If the saved mode is disabled/unavailable, fall back to an enabled option.
1304
+ if saved_conv not in self.conv_actions or not self.conv_actions[saved_conv].isEnabled():
1305
+ # Prefer 'rdkit' if available, else pick whichever action is enabled
1306
+ preferred = 'rdkit' if 'rdkit' in self.conv_actions and self.conv_actions['rdkit'].isEnabled() else None
1307
+ if not preferred:
1308
+ for k, act in self.conv_actions.items():
1309
+ if act.isEnabled():
1310
+ preferred = k
1311
+ break
1312
+ saved_conv = preferred or 'rdkit'
1313
+
1314
+ # Set the checked state and persist the chosen conversion mode
1315
+ try:
1316
+ if saved_conv in self.conv_actions:
1317
+ try:
1318
+ self.conv_actions[saved_conv].setChecked(True)
1319
+ except Exception:
1320
+ pass
1321
+ self.settings['3d_conversion_mode'] = saved_conv
1322
+ try:
1323
+ self.settings_dirty = True
1324
+ except Exception:
1325
+ pass
1326
+ except Exception:
1327
+ pass
1328
+
1329
+ # 3) 3D Optimization Settings (single location under Settings menu)
1330
+ optimization_menu = settings_menu.addMenu("3D Optimization Settings")
1331
+
1332
+ # Only RDKit-backed optimization methods are offered here.
1333
+ opt_methods = [
1334
+ ("MMFF94s", "MMFF_RDKIT"),
1335
+ ("MMFF94", "MMFF94_RDKIT"),
1336
+ ("UFF", "UFF_RDKIT"),
1337
+ ]
1338
+
1339
+ # Map key -> human-readable label for status messages and later lookups
1340
+ try:
1341
+ self.opt3d_method_labels = {key.upper(): label for (label, key) in opt_methods}
1342
+ except Exception:
1343
+ self.opt3d_method_labels = {}
1344
+
1345
+ opt_group = QActionGroup(self)
1346
+ opt_group.setExclusive(True)
1347
+ opt_actions = {}
1348
+ for label, key in opt_methods:
1349
+ action = QAction(label, self)
1350
+ action.setCheckable(True)
1351
+ try:
1352
+ action.setActionGroup(opt_group)
1353
+ except Exception:
1354
+ pass
1355
+ action.triggered.connect(lambda checked, m=key: self.set_optimization_method(m))
1356
+ optimization_menu.addAction(action)
1357
+ opt_group.addAction(action)
1358
+ opt_actions[key] = action
1359
+
1360
+ # Persist the actions mapping so other methods can update the checked state
1361
+ self.opt3d_actions = opt_actions
1362
+
1363
+ # Determine the initial checked menu item from saved settings (fall back to MMFF_RDKIT)
1364
+ try:
1365
+ saved_opt = (self.settings.get('optimization_method') or self.optimization_method or 'MMFF_RDKIT').upper()
1366
+ except Exception:
1367
+ saved_opt = 'MMFF_RDKIT'
1368
+
1369
+ try:
1370
+ if saved_opt in self.opt3d_actions and self.opt3d_actions[saved_opt].isEnabled():
1371
+ self.opt3d_actions[saved_opt].setChecked(True)
1372
+ self.optimization_method = saved_opt
1373
+ else:
1374
+ if 'MMFF_RDKIT' in self.opt3d_actions:
1375
+ self.opt3d_actions['MMFF_RDKIT'].setChecked(True)
1376
+ self.optimization_method = 'MMFF_RDKIT'
1377
+ except Exception:
1378
+ pass
1379
+
1380
+ # 4) Reset all settings to defaults
1381
+ settings_menu.addSeparator()
1382
+ reset_settings_action = QAction("Reset All Settings", self)
1383
+ reset_settings_action.triggered.connect(self.reset_all_settings_menu)
1384
+ settings_menu.addAction(reset_settings_action)
1385
+
1386
+ help_menu = menu_bar.addMenu("&Help")
1387
+ about_action = QAction("About", self)
1388
+ about_action.triggered.connect(self.show_about_dialog)
1389
+ help_menu.addAction(about_action)
1390
+
1391
+ github_action = QAction("GitHub", self)
1392
+ github_action.triggered.connect(
1393
+ lambda: QDesktopServices.openUrl(QUrl("https://github.com/HiroYokoyama/python_molecular_editor"))
1394
+ )
1395
+ help_menu.addAction(github_action)
1396
+
1397
+ github_wiki_action = QAction("GitHub Wiki", self)
1398
+ github_wiki_action.triggered.connect(
1399
+ lambda: QDesktopServices.openUrl(QUrl("https://github.com/HiroYokoyama/python_molecular_editor/wiki"))
1400
+ )
1401
+ help_menu.addAction(github_wiki_action)
1402
+
1403
+ manual_action = QAction("User Manual", self)
1404
+ manual_action.triggered.connect(
1405
+ lambda: QDesktopServices.openUrl(QUrl("https://hiroyokoyama.github.io/python_molecular_editor/manual/manual"))
1406
+ )
1407
+ help_menu.addAction(manual_action)
1408
+
1409
+
1410
+
1411
+ # 3D関連機能の初期状態を統一的に設定
1412
+ self._enable_3d_features(False)
1413
+
1414
+
1415
+
1416
+ def init_worker_thread(self):
1417
+ # Initialize shared state for calculation runs.
1418
+ # NOTE: we no longer create a persistent worker/thread here. Instead,
1419
+ # each conversion run will create its own CalculationWorker + QThread
1420
+ # so multiple conversions may run in parallel.
1421
+ # Shared halt id set used to request early termination of specific worker runs
1422
+ self.halt_ids = set()
1423
+ # IDs used to correlate start/halt/finish
1424
+ self.next_conversion_id = 1
1425
+ # Track currently-active conversion worker IDs so Halt can target all
1426
+ # running conversions. Use a set because multiple conversions may run
1427
+ # concurrently.
1428
+ self.active_worker_ids = set()
1429
+ # Track active threads for diagnostics/cleanup (weak references ok)
1430
+ try:
1431
+ self._active_calc_threads = []
1432
+ except Exception:
1433
+ self._active_calc_threads = []
1434
+
1435
+
1436
+
1437
+
1438
+ def load_command_line_file(self, file_path):
1439
+ """コマンドライン引数で指定されたファイルを開く"""
1440
+ if not file_path or not os.path.exists(file_path):
1441
+ return
1442
+
1443
+ # Helper for extension
1444
+ _, ext_with_dot = os.path.splitext(file_path)
1445
+ ext_with_dot = ext_with_dot.lower()
1446
+ # Legacy variable name (no dot)
1447
+ file_ext = ext_with_dot.lstrip('.')
1448
+
1449
+ # 1. Custom Plugin Openers
1450
+ # 1. Custom Plugin Openers
1451
+ if ext_with_dot in self.plugin_manager.file_openers:
1452
+ opener = self.plugin_manager.file_openers[ext_with_dot]
1453
+ try:
1454
+ opener['callback'](file_path)
1455
+ self.current_file_path = file_path
1456
+ self.update_window_title()
1457
+ return
1458
+ except Exception as e:
1459
+ print(f"Plugin opener failed: {e}")
1460
+ QMessageBox.warning(self, "Plugin Error", f"Error opening file with plugin '{opener.get('plugin', 'Unknown')}':\n{e}")
1461
+ # Fallback to standard logic if plugin fails? Or stop?
1462
+ # Generally if a plugin claims it, we stop. But here we let it fall through if it errors?
1463
+ # Let's simple check next.
1464
+
1465
+ if file_ext in ['mol', 'sdf']:
1466
+ self.load_mol_file_for_3d_viewing(file_path)
1467
+ elif file_ext == 'xyz':
1468
+ self.load_xyz_for_3d_viewing(file_path)
1469
+ elif file_ext in ['pmeraw', 'pmeprj']:
1470
+ self.open_project_file(file_path=file_path)
1471
+ else:
1472
+ self.statusBar().showMessage(f"Unsupported file type: {file_ext}")
1473
+
1474
+
1475
+
1476
+ def apply_initial_settings(self):
1477
+ """UIの初期化が完了した後に、保存された設定を3Dビューに適用する"""
1478
+
1479
+ try:
1480
+ self.update_cpk_colors_from_settings()
1481
+ except Exception:
1482
+ pass
1483
+
1484
+ if self.plotter and self.plotter.renderer:
1485
+ bg_color = self.settings.get('background_color', '#919191')
1486
+ self.plotter.set_background(bg_color)
1487
+ self.apply_3d_settings()
1488
+
1489
+ try:
1490
+ if hasattr(self, 'scene') and self.scene:
1491
+ for it in list(self.scene.items()):
1492
+ if hasattr(it, 'update_style'):
1493
+ it.update_style()
1494
+ self.scene.update()
1495
+ for v in list(self.scene.views()):
1496
+ v.viewport().update()
1497
+ except Exception:
1498
+ pass
1499
+
1500
+
1501
+
1502
+ def open_settings_dialog(self):
1503
+ dialog = SettingsDialog(self.settings, self)
1504
+ # accept()メソッドで設定の適用と3Dビューの更新を行うため、ここでは不要
1505
+ dialog.exec()
1506
+
1507
+
1508
+
1509
+
1510
+ def reset_all_settings_menu(self):
1511
+ # Expose the same functionality as SettingsDialog.reset_all_settings
1512
+ dlg = QMessageBox(self)
1513
+ dlg.setIcon(QMessageBox.Icon.Warning)
1514
+ dlg.setWindowTitle("Reset All Settings")
1515
+ dlg.setText("Are you sure you want to reset all settings to defaults?")
1516
+ dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1517
+ res = dlg.exec()
1518
+ if res == QMessageBox.StandardButton.Yes:
1519
+ try:
1520
+ # Remove settings file and reload defaults
1521
+ if os.path.exists(self.settings_file):
1522
+ os.remove(self.settings_file)
1523
+ self.load_settings()
1524
+ # Do not write to disk immediately; mark dirty so settings will be saved on exit
1525
+ try:
1526
+ self.settings_dirty = True
1527
+ except Exception:
1528
+ pass
1529
+ # If ColorSettingsDialog is open, refresh its UI to reflect the reset
1530
+ try:
1531
+ for w in QApplication.topLevelWidgets():
1532
+ try:
1533
+ if isinstance(w, ColorSettingsDialog):
1534
+ try:
1535
+ w.refresh_ui()
1536
+ except Exception:
1537
+ pass
1538
+ except Exception:
1539
+ pass
1540
+ except Exception:
1541
+ pass
1542
+ # Ensure global CPK mapping is rebuilt from defaults and UI is updated
1543
+ try:
1544
+ self.update_cpk_colors_from_settings()
1545
+ except Exception:
1546
+ pass
1547
+ # Refresh UI/menu state for conversion and optimization
1548
+ try:
1549
+ # update optimization method
1550
+ self.optimization_method = self.settings.get('optimization_method', 'MMFF_RDKIT')
1551
+ if hasattr(self, 'opt3d_actions') and self.optimization_method:
1552
+ key = (self.optimization_method or '').upper()
1553
+ if key in self.opt3d_actions:
1554
+ # uncheck all then check the saved one
1555
+ for act in self.opt3d_actions.values():
1556
+ act.setChecked(False)
1557
+ try:
1558
+ self.opt3d_actions[key].setChecked(True)
1559
+ except Exception:
1560
+ pass
1561
+
1562
+ # update conversion mode
1563
+ conv_mode = self.settings.get('3d_conversion_mode', 'fallback')
1564
+ if hasattr(self, 'conv_actions') and conv_mode in self.conv_actions:
1565
+ try:
1566
+ for act in self.conv_actions.values():
1567
+ act.setChecked(False)
1568
+ self.conv_actions[conv_mode].setChecked(True)
1569
+ except Exception:
1570
+ pass
1571
+
1572
+ # 3Dビューの設定を適用
1573
+ self.apply_3d_settings()
1574
+ # 現在の分子を再描画(設定変更を反映)
1575
+ if hasattr(self, 'current_mol') and self.current_mol:
1576
+ self.draw_molecule_3d(self.current_mol)
1577
+
1578
+ QMessageBox.information(self, "Reset Complete", "All settings have been reset to defaults.")
1579
+
1580
+ except Exception:
1581
+ pass
1582
+ # Update 2D scene styling to reflect default CPK colors
1583
+ try:
1584
+ if hasattr(self, 'scene') and self.scene:
1585
+ for it in list(self.scene.items()):
1586
+ try:
1587
+ if hasattr(it, 'update_style'):
1588
+ it.update_style()
1589
+ except Exception:
1590
+ pass
1591
+ try:
1592
+ # Force a full scene update and viewport repaint for all views
1593
+ self.scene.update()
1594
+ for v in list(self.scene.views()):
1595
+ try:
1596
+ v.viewport().update()
1597
+ except Exception:
1598
+ pass
1599
+ except Exception:
1600
+ pass
1601
+ except Exception:
1602
+ pass
1603
+ # Also refresh any open SettingsDialog instances so their UI matches
1604
+ try:
1605
+ for w in QApplication.topLevelWidgets():
1606
+ try:
1607
+ if isinstance(w, SettingsDialog):
1608
+ try:
1609
+ w.update_ui_from_settings(self.settings)
1610
+ except Exception:
1611
+ pass
1612
+ except Exception:
1613
+ pass
1614
+ except Exception:
1615
+ pass
1616
+ except Exception as e:
1617
+ QMessageBox.warning(self, "Reset Failed", f"Could not reset settings: {e}")
1618
+
1619
+
1620
+
1621
+
1622
+ def load_settings(self):
1623
+ default_settings = {
1624
+ 'background_color': '#919191',
1625
+ 'projection_mode': 'Perspective',
1626
+ 'lighting_enabled': True,
1627
+ 'specular': 0.2,
1628
+ 'specular_power': 20,
1629
+ 'light_intensity': 1.0,
1630
+ 'show_3d_axes': True,
1631
+ # Ball and Stick model parameters
1632
+ 'ball_stick_atom_scale': 1.0,
1633
+ 'ball_stick_bond_radius': 0.1,
1634
+ 'ball_stick_resolution': 16,
1635
+ # CPK (Space-filling) model parameters
1636
+ 'cpk_atom_scale': 1.0,
1637
+ 'cpk_resolution': 32,
1638
+ # Wireframe model parameters
1639
+ 'wireframe_bond_radius': 0.01,
1640
+ 'wireframe_resolution': 6,
1641
+ # Stick model parameters
1642
+ 'stick_bond_radius': 0.15,
1643
+ 'stick_resolution': 16,
1644
+ # Multiple bond offset parameters (per-model)
1645
+ 'ball_stick_double_bond_offset_factor': 2.0,
1646
+ 'ball_stick_triple_bond_offset_factor': 2.0,
1647
+ 'ball_stick_double_bond_radius_factor': 0.8,
1648
+ 'ball_stick_triple_bond_radius_factor': 0.75,
1649
+ 'wireframe_double_bond_offset_factor': 3.0,
1650
+ 'wireframe_triple_bond_offset_factor': 3.0,
1651
+ 'wireframe_double_bond_radius_factor': 0.8,
1652
+ 'wireframe_triple_bond_radius_factor': 0.75,
1653
+ 'stick_double_bond_offset_factor': 1.5,
1654
+ 'stick_triple_bond_offset_factor': 1.0,
1655
+ 'stick_double_bond_radius_factor': 0.60,
1656
+ 'stick_triple_bond_radius_factor': 0.40,
1657
+ 'aromatic_torus_thickness_factor': 0.6,
1658
+ # Ensure conversion/optimization defaults are present
1659
+ # If True, attempts to be permissive when RDKit raises chemical/sanitization errors
1660
+ # during file import (useful for viewing malformed XYZ/MOL files).
1661
+ 'skip_chemistry_checks': False,
1662
+ '3d_conversion_mode': 'fallback',
1663
+ 'optimization_method': 'MMFF_RDKIT',
1664
+ # Color overrides
1665
+ 'ball_stick_bond_color': '#7F7F7F',
1666
+ 'cpk_colors': {}, # symbol->hex overrides
1667
+ # Whether to kekulize aromatic systems for 3D display
1668
+ 'display_kekule_3d': False,
1669
+ 'always_ask_charge': False,
1670
+ }
1671
+
1672
+ try:
1673
+ if os.path.exists(self.settings_file):
1674
+ with open(self.settings_file, 'r') as f:
1675
+ loaded_settings = json.load(f)
1676
+ # Ensure any missing default keys are inserted and persisted.
1677
+ changed = False
1678
+ for key, value in default_settings.items():
1679
+ if key not in loaded_settings:
1680
+ loaded_settings[key] = value
1681
+ changed = True
1682
+
1683
+ self.settings = loaded_settings
1684
+
1685
+ # Migration: if older global multi-bond keys exist, copy them to per-model keys
1686
+ legacy_keys = ['double_bond_offset_factor', 'triple_bond_offset_factor', 'double_bond_radius_factor', 'triple_bond_radius_factor']
1687
+ migrated = False
1688
+ # If legacy keys exist, propagate to per-model keys when per-model keys missing
1689
+ if any(k in self.settings for k in legacy_keys):
1690
+ # For each per-model key, if missing, set from legacy fallback
1691
+ def copy_if_missing(new_key, legacy_key, default_val):
1692
+ nonlocal migrated
1693
+ if new_key not in self.settings:
1694
+ if legacy_key in self.settings:
1695
+ self.settings[new_key] = self.settings[legacy_key]
1696
+ migrated = True
1697
+ else:
1698
+ self.settings[new_key] = default_val
1699
+ migrated = True
1700
+
1701
+ per_model_map = [
1702
+ ('ball_stick_double_bond_offset_factor', 'double_bond_offset_factor', 2.0),
1703
+ ('ball_stick_triple_bond_offset_factor', 'triple_bond_offset_factor', 2.0),
1704
+ ('ball_stick_double_bond_radius_factor', 'double_bond_radius_factor', 0.8),
1705
+ ('ball_stick_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.75),
1706
+ ('wireframe_double_bond_offset_factor', 'double_bond_offset_factor', 3.0),
1707
+ ('wireframe_triple_bond_offset_factor', 'triple_bond_offset_factor', 3.0),
1708
+ ('wireframe_double_bond_radius_factor', 'double_bond_radius_factor', 0.8),
1709
+ ('wireframe_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.75),
1710
+ ('stick_double_bond_offset_factor', 'double_bond_offset_factor', 1.5),
1711
+ ('stick_triple_bond_offset_factor', 'triple_bond_offset_factor', 1.0),
1712
+ ('stick_double_bond_radius_factor', 'double_bond_radius_factor', 0.60),
1713
+ ('stick_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.40),
1714
+ ]
1715
+ for new_k, legacy_k, default_v in per_model_map:
1716
+ copy_if_missing(new_k, legacy_k, default_v)
1717
+
1718
+ # Optionally remove legacy keys to avoid confusion (keep them for now but mark dirty)
1719
+ if migrated:
1720
+ changed = True
1721
+
1722
+ # If we added any defaults (e.g. skip_chemistry_checks) or migrated keys, write them back so
1723
+ # the configuration file reflects the effective defaults without requiring
1724
+ # the user to edit the file manually.
1725
+ if changed:
1726
+ # Don't write immediately; mark dirty and let closeEvent persist
1727
+ try:
1728
+ self.settings_dirty = True
1729
+ except Exception:
1730
+ pass
1731
+
1732
+ else:
1733
+ # No settings file - use defaults. Mark dirty so defaults will be written on exit.
1734
+ self.settings = default_settings
1735
+ try:
1736
+ self.settings_dirty = True
1737
+ except Exception:
1738
+ pass
1739
+
1740
+ except Exception:
1741
+ self.settings = default_settings
1742
+
1743
+
1744
+
1745
+ def save_settings(self):
1746
+ try:
1747
+ if not os.path.exists(self.settings_dir):
1748
+ os.makedirs(self.settings_dir)
1749
+ with open(self.settings_file, 'w') as f:
1750
+ json.dump(self.settings, f, indent=4)
1751
+ except Exception as e:
1752
+ print(f"Error saving settings: {e}")
1753
+
1754
+ def update_plugin_menu(self, plugin_menu):
1755
+ """Discovers plugins and updates the plugin menu actions."""
1756
+ if not self.plugin_manager:
1757
+ return
1758
+
1759
+ PLUGIN_ACTION_TAG = "plugin_managed"
1760
+
1761
+ # Helper to clear tagged actions from a menu
1762
+ def clear_plugin_actions(menu):
1763
+ if not menu: return
1764
+ for act in list(menu.actions()):
1765
+ if act.data() == PLUGIN_ACTION_TAG:
1766
+ menu.removeAction(act)
1767
+ # Recurse into submenus to clean deep actions
1768
+ elif act.menu():
1769
+ clear_plugin_actions(act.menu())
1770
+
1771
+ # Clear existing plugin actions from main Plugin menu
1772
+ plugin_menu.clear()
1773
+
1774
+ # Clear tagged actions from ALL top-level menus in the Menu Bar
1775
+ # This ensures we catch actions added to standard menus (File, Edit) OR custom menus
1776
+ for top_action in self.menuBar().actions():
1777
+ if top_action.menu():
1778
+ clear_plugin_actions(top_action.menu())
1779
+
1780
+ # Clear Export menu (if button exists)
1781
+ if hasattr(self, 'export_button') and self.export_button.menu():
1782
+ clear_plugin_actions(self.export_button.menu())
1783
+
1784
+ # Only keep the Manager action
1785
+ manage_plugins_action = QAction("Plugin Manager...", self)
1786
+ def show_plugin_manager():
1787
+ from .plugin_manager_window import PluginManagerWindow
1788
+ dlg = PluginManagerWindow(self.plugin_manager, self)
1789
+ dlg.exec()
1790
+ self.update_plugin_menu(plugin_menu) # Refresh after closing
1791
+ manage_plugins_action.triggered.connect(show_plugin_manager)
1792
+ plugin_menu.addAction(manage_plugins_action)
1793
+
1794
+ plugin_menu.addSeparator()
1795
+
1796
+ # Add dynamic plugin actions (Legacy + New Registration)
1797
+ plugins = self.plugin_manager.discover_plugins(self)
1798
+
1799
+ # 1. Add Registered Menu Actions (New System)
1800
+ if self.plugin_manager.menu_actions:
1801
+ for action_def in self.plugin_manager.menu_actions:
1802
+ path = action_def['path']
1803
+ callback = action_def['callback']
1804
+ text = action_def['text']
1805
+ # Create/Find menu path
1806
+ current_menu = self.menuBar() # Or find specific top-level
1807
+
1808
+ # Handling top-level menus vs nested
1809
+ parts = path.split('/')
1810
+
1811
+ # If path starts with existing top-level (File, Edit, etc), grab it
1812
+ # Otherwise create new top-level
1813
+ top_level_title = parts[0]
1814
+ found_top = False
1815
+ for act in self.menuBar().actions():
1816
+ if act.menu() and act.text().replace('&', '') == top_level_title:
1817
+ current_menu = act.menu()
1818
+ found_top = True
1819
+ break
1820
+
1821
+ if not found_top:
1822
+ current_menu = self.menuBar().addMenu(top_level_title)
1823
+
1824
+ # Traverse rest
1825
+ for part in parts[1:-1]:
1826
+ found_sub = False
1827
+ for act in current_menu.actions():
1828
+ if act.menu() and act.text().replace('&', '') == part:
1829
+ current_menu = act.menu()
1830
+ found_sub = True
1831
+ break
1832
+ if not found_sub:
1833
+ current_menu = current_menu.addMenu(part)
1834
+
1835
+ # Add action
1836
+ action_text = text if text else parts[-1]
1837
+ action = QAction(action_text, self)
1838
+ action.triggered.connect(callback)
1839
+ action.setData(PLUGIN_ACTION_TAG) # TAG THE ACTION
1840
+ current_menu.addAction(action)
1841
+
1842
+ # 2. Add Toolbar Buttons (New System)
1843
+ # Use dedicated plugin toolbar
1844
+ if hasattr(self, 'plugin_toolbar'):
1845
+ self.plugin_toolbar.clear()
1846
+
1847
+ if self.plugin_manager.toolbar_actions:
1848
+ self.plugin_toolbar.show()
1849
+ for action_def in self.plugin_manager.toolbar_actions:
1850
+ text = action_def['text']
1851
+ callback = action_def['callback']
1852
+
1853
+ action = QAction(text, self)
1854
+ action.triggered.connect(callback)
1855
+ if action_def['icon']:
1856
+ if os.path.exists(action_def['icon']):
1857
+ action.setIcon(QIcon(action_def['icon']))
1858
+ if action_def['tooltip']:
1859
+ action.setToolTip(action_def['tooltip'])
1860
+ self.plugin_toolbar.addAction(action)
1861
+ else:
1862
+ self.plugin_toolbar.hide()
1863
+
1864
+ # 3. Legacy Menu Building (Folder based)
1865
+ if not plugins:
1866
+ no_plugin_action = QAction("(No plugins found)", self)
1867
+ no_plugin_action.setEnabled(False)
1868
+ plugin_menu.addAction(no_plugin_action)
1869
+ else:
1870
+ # Sort plugins: directories first (to create menus), then alphabetical by name
1871
+ plugins.sort(key=lambda x: (x.get('rel_folder', ''), x['name']))
1872
+
1873
+ # Dictionary to keep track of created submenus: path -> QMenu
1874
+ menus = { "": plugin_menu }
1875
+
1876
+ for p in plugins:
1877
+ # Only add legacy plugins (with 'run' function) to the generic Plugins menu.
1878
+ if hasattr(p['module'], 'run'):
1879
+ rel_folder = p.get('rel_folder', '')
1880
+ # Get or create the parent menu for this plugin
1881
+ parent_menu = menus.get("") # Start at root
1882
+
1883
+ if rel_folder:
1884
+ # Split path and traverse/create submenus
1885
+ parts = rel_folder.split(os.sep)
1886
+ current_path = ""
1887
+ for part in parts:
1888
+ new_path = os.path.join(current_path, part) if current_path else part
1889
+
1890
+ if new_path not in menus:
1891
+ # Create new submenu
1892
+ sub_menu = parent_menu.addMenu(part)
1893
+ menus[new_path] = sub_menu
1894
+
1895
+ parent_menu = menus[new_path]
1896
+ current_path = new_path
1897
+
1898
+ # Add action to the resolved parent_menu
1899
+ action = QAction(p['name'], self)
1900
+ action.triggered.connect(lambda checked, mod=p['module']: self.plugin_manager.run_plugin(mod, self))
1901
+ parent_menu.addAction(action)
1902
+
1903
+ # 4. Integrate Export Actions into Export Button and Menu
1904
+ # 4. Integrate Export Actions into Export Button AND Main File->Export Menu
1905
+ if self.plugin_manager.export_actions:
1906
+ # Find Main File -> Export menu
1907
+ main_export_menu = None
1908
+ for top_action in self.menuBar().actions():
1909
+ if top_action.text().replace('&', '') == 'File' and top_action.menu():
1910
+ for sub_action in top_action.menu().actions():
1911
+ if sub_action.text().replace('&', '') == 'Export' and sub_action.menu():
1912
+ main_export_menu = sub_action.menu()
1913
+ break
1914
+ if main_export_menu: break
1915
+
1916
+ # List of menus to populate
1917
+ target_menus = []
1918
+ if hasattr(self, 'export_button') and self.export_button.menu():
1919
+ target_menus.append(self.export_button.menu())
1920
+ if main_export_menu:
1921
+ target_menus.append(main_export_menu)
1922
+
1923
+ for menu in target_menus:
1924
+ # Add separator
1925
+ sep = menu.addSeparator()
1926
+ sep.setData(PLUGIN_ACTION_TAG)
1927
+
1928
+ for exp in self.plugin_manager.export_actions:
1929
+ label = exp['label']
1930
+ callback = exp['callback']
1931
+
1932
+ a = QAction(label, self)
1933
+ a.triggered.connect(callback)
1934
+ a.setData(PLUGIN_ACTION_TAG)
1935
+ menu.addAction(a)
1936
+
1937
+ # 5. Integrate File Openers into Import Menu
1938
+ # 5. Integrate File Openers into Import Menu
1939
+ if hasattr(self, 'import_menu') and self.plugin_manager.file_openers:
1940
+ # Add separator
1941
+ sep = self.import_menu.addSeparator()
1942
+ sep.setData(PLUGIN_ACTION_TAG)
1943
+
1944
+ # Group by Plugin Name
1945
+ plugin_map = {}
1946
+ for ext, info in self.plugin_manager.file_openers.items():
1947
+ p_name = info.get('plugin', 'Plugin')
1948
+ if p_name not in plugin_map:
1949
+ plugin_map[p_name] = {}
1950
+ plugin_map[p_name][ext] = info['callback']
1951
+
1952
+ for p_name, ext_map in plugin_map.items():
1953
+ # Create combined label: "Import .ext1/.ext2 (PluginName)..."
1954
+ extensions = sorted(ext_map.keys())
1955
+ ext_str = "/".join(extensions)
1956
+ label = f"Import {ext_str} ({p_name})..."
1957
+
1958
+ # Create combined filter: "PluginName Files (*.ext1 *.ext2)"
1959
+ filter_exts = " ".join([f"*{e}" for e in extensions])
1960
+ filter_str = f"{p_name} Files ({filter_exts});;All Files (*)"
1961
+
1962
+ # Factory for callback to fix closure capture
1963
+ def make_unified_cb(extensions_map, dialog_filter, plugin_nm):
1964
+ def _cb():
1965
+ fpath, _ = QFileDialog.getOpenFileName(
1966
+ self, f"Import {plugin_nm} Files", "",
1967
+ dialog_filter
1968
+ )
1969
+ if fpath:
1970
+ _, extension = os.path.splitext(fpath)
1971
+ extension = extension.lower()
1972
+ # Dispatch to specific callback
1973
+ if extension in extensions_map:
1974
+ extensions_map[extension](fpath)
1975
+ self.current_file_path = fpath
1976
+ self.update_window_title()
1977
+ else:
1978
+ self.statusBar().showMessage(f"No handler for extension {extension}")
1979
+ return _cb
1980
+
1981
+ a = QAction(label, self)
1982
+ a.triggered.connect(make_unified_cb(ext_map, filter_str, p_name))
1983
+ a.setData(PLUGIN_ACTION_TAG)
1984
+ self.import_menu.addAction(a)
1985
+
1986
+ # 6. Integrate Analysis Tools into Analysis Menu
1987
+ # Find Analysis menu again as it might not be defined if cleanup block was generic
1988
+ analysis_menu = None
1989
+ for action in self.menuBar().actions():
1990
+ if action.text().replace('&', '') == 'Analysis':
1991
+ analysis_menu = action.menu()
1992
+ break
1993
+
1994
+ if analysis_menu and self.plugin_manager.analysis_tools:
1995
+ # Add separator
1996
+ sep = analysis_menu.addSeparator()
1997
+ sep.setData(PLUGIN_ACTION_TAG)
1998
+
1999
+ for tool in self.plugin_manager.analysis_tools:
2000
+ label = f"{tool['label']} ({tool.get('plugin', 'Plugin')})"
2001
+
2002
+ a = QAction(label, self)
2003
+ a.triggered.connect(tool['callback'])
2004
+ a.setData(PLUGIN_ACTION_TAG)
2005
+ analysis_menu.addAction(a)
2006
+