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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- 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
|
+
|