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