MoleditPy-linux 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,692 @@
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
+ from PyQt6.QtWidgets import (
14
+ QDialog, QVBoxLayout, QLabel, QWidget, QGridLayout, QScrollArea,
15
+ QHBoxLayout, QPushButton, QGraphicsScene, QInputDialog, QMessageBox
16
+ )
17
+ from PyQt6.QtGui import QPainter, QFont, QColor, QPen, QBrush
18
+ from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QDateTime, QLineF
19
+ from .template_preview_view import TemplatePreviewView
20
+ try:
21
+ from .constants import VERSION, CPK_COLORS
22
+ except Exception:
23
+ from modules.constants import VERSION, CPK_COLORS
24
+ import os
25
+ import json
26
+ import logging
27
+
28
+ class UserTemplateDialog(QDialog):
29
+ """ユーザーテンプレート管理ダイアログ"""
30
+
31
+ def __init__(self, main_window, parent=None):
32
+ super().__init__(parent)
33
+ self.main_window = main_window
34
+ self.user_templates = []
35
+ self.selected_template = None
36
+ self.init_ui()
37
+ self.load_user_templates()
38
+
39
+ def init_ui(self):
40
+ self.setWindowTitle("User Templates")
41
+ self.setModal(False) # モードレスに変更
42
+ self.resize(800, 600)
43
+
44
+ # ウィンドウを右上に配置
45
+ if self.parent():
46
+ parent_geometry = self.parent().geometry()
47
+ x = parent_geometry.right() - self.width() - 20
48
+ y = parent_geometry.top() + 50
49
+ self.move(x, y)
50
+
51
+ layout = QVBoxLayout(self)
52
+
53
+ # Instructions
54
+ instruction_label = QLabel("Create and manage your custom molecular templates. Click a template to use it in the editor.")
55
+ instruction_label.setWordWrap(True)
56
+ layout.addWidget(instruction_label)
57
+
58
+ # Template grid
59
+ self.template_widget = QWidget()
60
+ self.template_layout = QGridLayout(self.template_widget)
61
+ self.template_layout.setSpacing(10)
62
+
63
+ scroll_area = QScrollArea()
64
+ scroll_area.setWidget(self.template_widget)
65
+ scroll_area.setWidgetResizable(True)
66
+ scroll_area.setMinimumHeight(400)
67
+ layout.addWidget(scroll_area)
68
+
69
+ # Buttons
70
+ button_layout = QHBoxLayout()
71
+
72
+ self.save_current_button = QPushButton("Save Current 2D as Template")
73
+ self.save_current_button.clicked.connect(self.save_current_as_template)
74
+ button_layout.addWidget(self.save_current_button)
75
+
76
+ button_layout.addStretch()
77
+
78
+ self.delete_button = QPushButton("Delete Selected")
79
+ self.delete_button.clicked.connect(self.delete_selected_template)
80
+ self.delete_button.setEnabled(False)
81
+ button_layout.addWidget(self.delete_button)
82
+
83
+ close_button = QPushButton("Close")
84
+ close_button.clicked.connect(self.close)
85
+ button_layout.addWidget(close_button)
86
+
87
+ layout.addLayout(button_layout)
88
+
89
+ def closeEvent(self, event):
90
+ """ダイアログクローズ時にモードをリセット"""
91
+ self.cleanup_template_mode()
92
+ super().closeEvent(event)
93
+
94
+ def cleanup_template_mode(self):
95
+ """テンプレートモードを終了し、atom_C(炭素描画)モードに戻す (Defensive implementation)"""
96
+ # 1. Reset Dialog State
97
+ self.selected_template = None
98
+ if hasattr(self, 'delete_button'):
99
+ self.delete_button.setEnabled(False)
100
+
101
+ # 2. Reset Main Window Mode (UI/Toolbar)
102
+ target_mode = 'atom_C'
103
+ try:
104
+ if hasattr(self.main_window, 'set_mode_and_update_toolbar'):
105
+ self.main_window.set_mode_and_update_toolbar(target_mode)
106
+ elif hasattr(self.main_window, 'set_mode'):
107
+ self.main_window.set_mode(target_mode)
108
+
109
+ # Fallback: set attribute directly if methods fail/don't exist
110
+ if hasattr(self.main_window, 'mode'):
111
+ self.main_window.mode = target_mode
112
+ except Exception as e:
113
+ logging.error(f"Error resetting main window mode: {e}")
114
+
115
+ # 3. Reset Scene State (The Source of Truth)
116
+ try:
117
+ if hasattr(self.main_window, 'scene') and self.main_window.scene:
118
+ scene = self.main_window.scene
119
+
120
+ # A. FORCE MODE
121
+ scene.mode = target_mode
122
+ scene.current_atom_symbol = 'C'
123
+
124
+ # B. Clear Data
125
+ if hasattr(scene, 'user_template_data'):
126
+ scene.user_template_data = None
127
+ if hasattr(scene, 'template_context'):
128
+ scene.template_context = {}
129
+
130
+ # C. Clear/Hide Preview Item
131
+ if hasattr(scene, 'clear_template_preview'):
132
+ scene.clear_template_preview()
133
+
134
+ if hasattr(scene, 'template_preview') and scene.template_preview:
135
+ scene.template_preview.hide()
136
+
137
+ # D. Reset Cursor & View
138
+ if scene.views():
139
+ view = scene.views()[0]
140
+ view.setCursor(Qt.CursorShape.CrossCursor)
141
+ view.viewport().update()
142
+
143
+ scene.update()
144
+ except Exception as e:
145
+ logging.error(f"Error cleaning up scene state: {e}")
146
+
147
+ def resizeEvent(self, event):
148
+ """ダイアログリサイズ時にテンプレートプレビューを再フィット"""
149
+ super().resizeEvent(event)
150
+ # Delay the refit to ensure proper widget sizing
151
+ QTimer.singleShot(100, self.refit_all_previews)
152
+
153
+ def refit_all_previews(self):
154
+ """すべてのテンプレートプレビューを再フィット"""
155
+ try:
156
+ for i in range(self.template_layout.count()):
157
+ item = self.template_layout.itemAt(i)
158
+ if item and item.widget():
159
+ widget = item.widget()
160
+ # Find the TemplatePreviewView within this widget
161
+ for child in widget.findChildren(TemplatePreviewView):
162
+ if hasattr(child, 'redraw_with_current_size'):
163
+ # Use redraw for better scaling adaptation
164
+ child.redraw_with_current_size()
165
+ elif hasattr(child, 'refit_view'):
166
+ child.refit_view()
167
+ except Exception as e:
168
+ logging.warning(f"Warning: Failed to refit template previews: {e}")
169
+
170
+ def showEvent(self, event):
171
+ """ダイアログ表示時にプレビューを適切にフィット"""
172
+ super().showEvent(event)
173
+ # Ensure all previews are properly fitted when dialog becomes visible
174
+ QTimer.singleShot(300, self.refit_all_previews)
175
+
176
+ def get_template_directory(self):
177
+ """テンプレートディレクトリのパスを取得"""
178
+ template_dir = os.path.join(self.main_window.settings_dir, 'user-templates')
179
+ if not os.path.exists(template_dir):
180
+ os.makedirs(template_dir)
181
+ return template_dir
182
+
183
+ def load_user_templates(self):
184
+ """ユーザーテンプレートを読み込み"""
185
+ template_dir = self.get_template_directory()
186
+ self.user_templates.clear()
187
+
188
+ try:
189
+ for filename in os.listdir(template_dir):
190
+ if filename.endswith('.pmetmplt'):
191
+ filepath = os.path.join(template_dir, filename)
192
+ template_data = self.load_template_file(filepath)
193
+ if template_data:
194
+ template_data['filename'] = filename
195
+ template_data['filepath'] = filepath
196
+ self.user_templates.append(template_data)
197
+ except Exception as e:
198
+ logging.error(f"Error loading user templates: {e}")
199
+
200
+ self.update_template_grid()
201
+
202
+ def load_template_file(self, filepath):
203
+ """テンプレートファイルを読み込み"""
204
+ try:
205
+ with open(filepath, 'r', encoding='utf-8') as f:
206
+ return json.load(f)
207
+ except Exception as e:
208
+ logging.error(f"Error loading template file {filepath}: {e}")
209
+ return None
210
+
211
+ def save_template_file(self, filepath, template_data):
212
+ """テンプレートファイルを保存"""
213
+ try:
214
+ with open(filepath, 'w', encoding='utf-8') as f:
215
+ json.dump(template_data, f, indent=2, ensure_ascii=False)
216
+ return True
217
+ except Exception as e:
218
+ logging.error(f"Error saving template file {filepath}: {e}")
219
+ return False
220
+
221
+ def update_template_grid(self):
222
+ """テンプレートグリッドを更新"""
223
+ # Clear existing widgets
224
+ for i in reversed(range(self.template_layout.count())):
225
+ self.template_layout.itemAt(i).widget().setParent(None)
226
+
227
+ # Add template previews (left-to-right, top-to-bottom ordering)
228
+ cols = 4
229
+ for i, template in enumerate(self.user_templates):
230
+ row = i // cols
231
+ col = i % cols # Left-to-right ordering
232
+
233
+ preview_widget = self.create_template_preview(template)
234
+ self.template_layout.addWidget(preview_widget, row, col)
235
+
236
+ # Ensure all previews are properly fitted after grid update
237
+ QTimer.singleShot(200, self.refit_all_previews)
238
+
239
+ def create_template_preview(self, template_data):
240
+ """テンプレートプレビューウィジェットを作成"""
241
+ widget = QWidget()
242
+ widget.setFixedSize(180, 200)
243
+ widget.setStyleSheet("""
244
+ QWidget {
245
+ border: 2px solid #ccc;
246
+ border-radius: 8px;
247
+ background-color: white;
248
+ }
249
+ QWidget:hover {
250
+ border-color: #007acc;
251
+ background-color: #f0f8ff;
252
+ }
253
+ """)
254
+
255
+ layout = QVBoxLayout(widget)
256
+
257
+ # Preview graphics - use custom view class for better resize handling
258
+ preview_scene = QGraphicsScene()
259
+ preview_view = TemplatePreviewView(preview_scene)
260
+ preview_view.setFixedSize(160, 140)
261
+ preview_view.setRenderHint(QPainter.RenderHint.Antialiasing)
262
+ preview_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
263
+ preview_view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
264
+
265
+ # Set template data for dynamic redrawing
266
+ preview_view.set_template_data(template_data, self)
267
+
268
+ # Draw template structure with view size for proper scaling
269
+ view_size = (preview_view.width(), preview_view.height())
270
+ self.draw_template_preview(preview_scene, template_data, view_size)
271
+
272
+ # Improved fitting approach with better error handling
273
+ bounding_rect = preview_scene.itemsBoundingRect()
274
+ if not bounding_rect.isEmpty() and bounding_rect.width() > 0 and bounding_rect.height() > 0:
275
+ # Calculate appropriate padding based on content size
276
+ content_size = max(bounding_rect.width(), bounding_rect.height())
277
+ padding = max(20, content_size * 0.2) # At least 20 units or 20% of content
278
+
279
+ padded_rect = bounding_rect.adjusted(-padding, -padding, padding, padding)
280
+ preview_scene.setSceneRect(padded_rect)
281
+
282
+ # Store original scene rect for proper fitting on resize
283
+ preview_view.original_scene_rect = padded_rect
284
+
285
+ # Use QTimer to ensure fitInView happens after widget is fully initialized
286
+ QTimer.singleShot(0, lambda: self.fit_preview_view_safely(preview_view, padded_rect))
287
+ else:
288
+ # Default view for empty or invalid content
289
+ default_rect = QRectF(-50, -50, 100, 100)
290
+ preview_scene.setSceneRect(default_rect)
291
+ preview_view.original_scene_rect = default_rect
292
+ QTimer.singleShot(0, lambda: self.fit_preview_view_safely(preview_view, default_rect))
293
+
294
+ layout.addWidget(preview_view)
295
+
296
+ # Template name
297
+ name = template_data.get('name', 'Unnamed Template')
298
+ name_label = QLabel(name)
299
+ name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
300
+ name_label.setWordWrap(True)
301
+ layout.addWidget(name_label)
302
+
303
+ # Mouse events
304
+ widget.mousePressEvent = lambda event: self.select_template(template_data, widget)
305
+ widget.mouseDoubleClickEvent = lambda event: self.use_template(template_data)
306
+
307
+ return widget
308
+
309
+ def fit_preview_view_safely(self, view, rect):
310
+ """プレビュービューを安全にフィット"""
311
+ try:
312
+ if view and not rect.isEmpty():
313
+ view.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
314
+ except Exception as e:
315
+ logging.warning(f"Warning: Failed to fit preview view: {e}")
316
+
317
+ def draw_template_preview(self, scene, template_data, view_size=None):
318
+ """テンプレートプレビューを描画 - fitInView縮小率に基づく動的スケーリング"""
319
+ atoms = template_data.get('atoms', [])
320
+ bonds = template_data.get('bonds', [])
321
+
322
+ if not atoms:
323
+ # Add placeholder text when no atoms
324
+ text = scene.addText("No structure", QFont('Arial', 12))
325
+ text.setDefaultTextColor(QColor('gray'))
326
+ return
327
+
328
+ # Calculate molecular dimensions
329
+ positions = [QPointF(atom['x'], atom['y']) for atom in atoms]
330
+ min_x = min(pos.x() for pos in positions)
331
+ max_x = max(pos.x() for pos in positions)
332
+ min_y = min(pos.y() for pos in positions)
333
+ max_y = max(pos.y() for pos in positions)
334
+
335
+ mol_width = max_x - min_x
336
+ mol_height = max_y - min_y
337
+ mol_size = max(mol_width, mol_height)
338
+
339
+ # Calculate fit scale factor (how much fitInView will shrink the content)
340
+ if view_size is None:
341
+ view_size = (160, 140) # Default preview view size
342
+
343
+ view_width, view_height = view_size
344
+
345
+ if mol_size > 0 and mol_width > 0 and mol_height > 0:
346
+ # Calculate the padding that will be added
347
+ padding = max(20, mol_size * 0.2)
348
+ padded_width = mol_width + 2 * padding
349
+ padded_height = mol_height + 2 * padding
350
+
351
+ # Calculate how much fitInView will scale down the content
352
+ # fitInView fits the padded rectangle into the view while maintaining aspect ratio
353
+ fit_scale_x = view_width / padded_width
354
+ fit_scale_y = view_height / padded_height
355
+ fit_scale = min(fit_scale_x, fit_scale_y) # fitInView uses the smaller scale
356
+
357
+ # Compensate for the fit scaling to maintain visual thickness
358
+ # When fit_scale is small (content heavily shrunk), we need thicker lines/fonts
359
+ if fit_scale > 0:
360
+ scale_factor = max(0.4, min(4.0, 1.0 / fit_scale))
361
+ else:
362
+ scale_factor = 4.0
363
+
364
+ # Debug info (can be removed in production)
365
+ # logging.debug(f"Mol size: {mol_size:.1f}, Fit scale: {fit_scale:.3f}, Scale factor: {scale_factor:.2f}")
366
+ else:
367
+ scale_factor = 1.0
368
+
369
+ # Base sizes that look good at 1:1 scale
370
+ base_bond_width = 1.8
371
+ base_font_size = 11
372
+ base_ellipse_width = 18
373
+ base_ellipse_height = 14
374
+ base_double_bond_offset = 3.5
375
+ base_triple_bond_offset = 2.5
376
+
377
+ # Apply inverse fit scaling to maintain visual consistency
378
+ bond_width = max(1.0, min(8.0, base_bond_width * scale_factor))
379
+ font_size = max(8, min(24, int(base_font_size * scale_factor)))
380
+ ellipse_width = max(10, min(40, base_ellipse_width * scale_factor))
381
+ ellipse_height = max(8, min(30, base_ellipse_height * scale_factor))
382
+ double_bond_offset = max(2.0, min(10.0, base_double_bond_offset * scale_factor))
383
+ triple_bond_offset = max(1.5, min(8.0, base_triple_bond_offset * scale_factor))
384
+
385
+ # Create atom ID to index mapping for bond drawing
386
+ atom_id_to_index = {}
387
+ for i, atom in enumerate(atoms):
388
+ atom_id = atom.get('id', i) # Use id if available, otherwise use index
389
+ atom_id_to_index[atom_id] = i
390
+
391
+ # Draw bonds first using original coordinates with dynamic sizing
392
+ for bond in bonds:
393
+ atom1_id, atom2_id = bond['atom1'], bond['atom2']
394
+
395
+ # Get atom indices from IDs
396
+ atom1_idx = atom_id_to_index.get(atom1_id)
397
+ atom2_idx = atom_id_to_index.get(atom2_id)
398
+
399
+ if atom1_idx is not None and atom2_idx is not None and atom1_idx < len(atoms) and atom2_idx < len(atoms):
400
+ pos1 = QPointF(atoms[atom1_idx]['x'], atoms[atom1_idx]['y'])
401
+ pos2 = QPointF(atoms[atom2_idx]['x'], atoms[atom2_idx]['y'])
402
+
403
+ # Draw bonds with proper order - dynamic thickness
404
+ bond_order = bond.get('order', 1)
405
+ pen = QPen(QColor('black'), bond_width)
406
+
407
+ if bond_order == 2:
408
+ # Double bond - draw two parallel lines
409
+ line = QLineF(pos1, pos2)
410
+ if line.length() > 0:
411
+ normal = line.normalVector()
412
+ normal.setLength(double_bond_offset)
413
+
414
+ line1 = QLineF(pos1 + normal.p2() - normal.p1(), pos2 + normal.p2() - normal.p1())
415
+ line2 = QLineF(pos1 - normal.p2() + normal.p1(), pos2 - normal.p2() + normal.p1())
416
+
417
+ scene.addLine(line1, pen)
418
+ scene.addLine(line2, pen)
419
+ else:
420
+ scene.addLine(line, pen)
421
+ elif bond_order == 3:
422
+ # Triple bond - draw three parallel lines
423
+ line = QLineF(pos1, pos2)
424
+ if line.length() > 0:
425
+ normal = line.normalVector()
426
+ normal.setLength(triple_bond_offset)
427
+
428
+ # Center line
429
+ scene.addLine(line, pen)
430
+ # Side lines
431
+ line1 = QLineF(pos1 + normal.p2() - normal.p1(), pos2 + normal.p2() - normal.p1())
432
+ line2 = QLineF(pos1 - normal.p2() + normal.p1(), pos2 - normal.p2() + normal.p1())
433
+
434
+ scene.addLine(line1, pen)
435
+ scene.addLine(line2, pen)
436
+ else:
437
+ scene.addLine(line, pen)
438
+ else:
439
+ # Single bond
440
+ scene.addLine(QLineF(pos1, pos2), pen)
441
+
442
+ # Draw only non-carbon atom labels with dynamic sizing
443
+ for i, atom in enumerate(atoms):
444
+ try:
445
+ pos = QPointF(atom['x'], atom['y'])
446
+ symbol = atom.get('symbol', 'C')
447
+
448
+ # Draw atoms - white ellipse background to hide bonds, then CPK colored text
449
+ if symbol != 'C':
450
+ # All non-carbon atoms including hydrogen: white background ellipse + CPK colored text
451
+ color = CPK_COLORS.get(symbol, CPK_COLORS.get('DEFAULT', QColor('#FF1493')))
452
+
453
+ # Add white background ellipse to hide bonds - dynamic size
454
+ pen = QPen(Qt.GlobalColor.white, 0) # No border
455
+ brush = QBrush(Qt.GlobalColor.white)
456
+ ellipse_x = pos.x() - ellipse_width/2
457
+ ellipse_y = pos.y() - ellipse_height/2
458
+ ellipse = scene.addEllipse(ellipse_x, ellipse_y, ellipse_width, ellipse_height, pen, brush)
459
+
460
+ # Add CPK colored text label on top - dynamic font size
461
+ font = QFont("Arial", font_size, QFont.Weight.Bold)
462
+ text = scene.addText(symbol, font)
463
+ text.setDefaultTextColor(color) # CPK colored text
464
+ text_rect = text.boundingRect()
465
+ text.setPos(pos.x() - text_rect.width()/2, pos.y() - text_rect.height()/2)
466
+
467
+ except Exception:
468
+ continue
469
+
470
+ def select_template(self, template_data, widget):
471
+ """テンプレートを選択してテンプレートモードに切り替え"""
472
+ # Clear previous selection styling
473
+ for i in range(self.template_layout.count()):
474
+ item = self.template_layout.itemAt(i)
475
+ if item and item.widget():
476
+ item.widget().setStyleSheet("""
477
+ QWidget {
478
+ border: 2px solid #ccc;
479
+ border-radius: 8px;
480
+ background-color: white;
481
+ }
482
+ QWidget:hover {
483
+ border-color: #007acc;
484
+ background-color: #f0f8ff;
485
+ }
486
+ """)
487
+
488
+ # Highlight selected widget - only border, no background change
489
+ widget.setStyleSheet("""
490
+ QWidget {
491
+ border: 3px solid #007acc;
492
+ border-radius: 8px;
493
+ background-color: white;
494
+ }
495
+ """)
496
+
497
+ self.selected_template = template_data
498
+ self.delete_button.setEnabled(True)
499
+
500
+ # Automatically switch to template mode when template is selected
501
+ template_name = template_data.get('name', 'user_template')
502
+ mode_name = f"template_user_{template_name}"
503
+
504
+ # Store template data for the scene to use
505
+ try:
506
+ self.main_window.scene.user_template_data = template_data
507
+ except Exception:
508
+ # Best-effort: ignore if scene or attribute missing
509
+ pass
510
+
511
+ # Force the main window into the template mode.
512
+ # Clear or uncheck any existing mode actions if present to avoid staying in another mode.
513
+ try:
514
+ # Uncheck all mode actions first (if a dict of QAction exists)
515
+ if hasattr(self.main_window, 'mode_actions') and isinstance(self.main_window.mode_actions, dict):
516
+ for act in self.main_window.mode_actions.values():
517
+ try:
518
+ act.setChecked(False)
519
+ except Exception:
520
+ continue
521
+
522
+ # If main_window has a set_mode method, call it. Otherwise, try to set a mode attribute.
523
+ if hasattr(self.main_window, 'set_mode') and callable(self.main_window.set_mode):
524
+ self.main_window.set_mode(mode_name)
525
+ else:
526
+ # Fallback: set an attribute and try to update UI
527
+ setattr(self.main_window, 'mode', mode_name)
528
+
529
+ # Update UI
530
+ try:
531
+ self.main_window.statusBar().showMessage(f"Template mode: {template_name}")
532
+ except Exception:
533
+ # ignore status bar failures
534
+ pass
535
+
536
+ # If there is a matching QAction in mode_actions, check it
537
+ try:
538
+ if hasattr(self.main_window, 'mode_actions') and f"template_user_{template_name}" in self.main_window.mode_actions:
539
+ self.main_window.mode_actions[f"template_user_{template_name}"].setChecked(True)
540
+ except Exception:
541
+ pass
542
+ except Exception as e:
543
+ logging.warning(f"Warning: Failed to switch main window to template mode: {e}")
544
+
545
+ def use_template(self, template_data):
546
+ """テンプレートを使用(エディタに適用)"""
547
+ try:
548
+ # Switch to template mode
549
+ template_name = template_data.get('name', 'user_template')
550
+ mode_name = f"template_user_{template_name}"
551
+
552
+ # Store template data for the scene to use
553
+ try:
554
+ self.main_window.scene.user_template_data = template_data
555
+ except Exception:
556
+ pass
557
+
558
+ # Force the main window into the template mode (same approach as select_template)
559
+ try:
560
+ if hasattr(self.main_window, 'mode_actions') and isinstance(self.main_window.mode_actions, dict):
561
+ for act in self.main_window.mode_actions.values():
562
+ try:
563
+ act.setChecked(False)
564
+ except Exception:
565
+ continue
566
+
567
+ if hasattr(self.main_window, 'set_mode') and callable(self.main_window.set_mode):
568
+ self.main_window.set_mode(mode_name)
569
+ else:
570
+ setattr(self.main_window, 'mode', mode_name)
571
+
572
+ try:
573
+ self.main_window.statusBar().showMessage(f"Template mode: {template_name}")
574
+ except Exception:
575
+ pass
576
+
577
+ # Mark selected and keep dialog open
578
+ self.selected_template = template_data
579
+ except Exception as e:
580
+ logging.warning(f"Warning: Failed to switch main window to template mode: {e}")
581
+
582
+ # Don't close dialog - keep it open for easy template switching
583
+ # self.accept()
584
+
585
+ except Exception as e:
586
+ QMessageBox.critical(self, "Error", f"Failed to apply template: {str(e)}")
587
+
588
+ def save_current_as_template(self):
589
+ """現在の2D構造をテンプレートとして保存"""
590
+ if not self.main_window.data.atoms:
591
+ QMessageBox.warning(self, "Warning", "No structure to save as template.")
592
+ return
593
+
594
+ # Get template name
595
+ name, ok = QInputDialog.getText(self, "Save Template", "Enter template name:")
596
+ if not ok or not name.strip():
597
+ return
598
+
599
+ name = name.strip()
600
+
601
+ try:
602
+ # Convert current structure to template format
603
+ template_data = self.convert_structure_to_template(name)
604
+
605
+ # Save to file
606
+ filename = f"{name.replace(' ', '_')}.pmetmplt"
607
+ filepath = os.path.join(self.get_template_directory(), filename)
608
+
609
+ if os.path.exists(filepath):
610
+ reply = QMessageBox.question(
611
+ self, "Overwrite Template",
612
+ f"Template '{name}' already exists. Overwrite?",
613
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
614
+ )
615
+ if reply != QMessageBox.StandardButton.Yes:
616
+ return
617
+
618
+ if self.save_template_file(filepath, template_data):
619
+ # Mark main window as saved
620
+ self.main_window.has_unsaved_changes = False
621
+ self.main_window.update_window_title()
622
+
623
+ QMessageBox.information(self, "Success", f"Template '{name}' saved successfully.")
624
+ self.load_user_templates() # Refresh the display
625
+ else:
626
+ QMessageBox.critical(self, "Error", "Failed to save template.")
627
+
628
+ except Exception as e:
629
+ QMessageBox.critical(self, "Error", f"Failed to save template: {str(e)}")
630
+
631
+ def convert_structure_to_template(self, name):
632
+ """現在の構造をテンプレート形式に変換"""
633
+ atoms_data = []
634
+ bonds_data = []
635
+
636
+ # Convert atoms
637
+ for atom_id, atom_info in self.main_window.data.atoms.items():
638
+ pos = atom_info['pos']
639
+ atoms_data.append({
640
+ 'id': atom_id,
641
+ 'symbol': atom_info['symbol'],
642
+ 'x': pos.x(),
643
+ 'y': pos.y(),
644
+ 'charge': atom_info.get('charge', 0),
645
+ 'radical': atom_info.get('radical', 0)
646
+ })
647
+
648
+ # Convert bonds
649
+ for (atom1_id, atom2_id), bond_info in self.main_window.data.bonds.items():
650
+ bonds_data.append({
651
+ 'atom1': atom1_id,
652
+ 'atom2': atom2_id,
653
+ 'order': bond_info['order'],
654
+ 'stereo': bond_info.get('stereo', 0)
655
+ })
656
+
657
+ # Create template data
658
+ template_data = {
659
+ 'format': "PME Template",
660
+ 'version': "1.0",
661
+ 'application': "MoleditPy",
662
+ 'application_version': VERSION,
663
+ 'name': name,
664
+ 'created': str(QDateTime.currentDateTime().toString()),
665
+ 'atoms': atoms_data,
666
+ 'bonds': bonds_data
667
+ }
668
+
669
+ return template_data
670
+
671
+ def delete_selected_template(self):
672
+ """選択されたテンプレートを削除"""
673
+ if not self.selected_template:
674
+ return
675
+
676
+ name = self.selected_template.get('name', 'Unknown')
677
+ reply = QMessageBox.question(
678
+ self, "Delete Template",
679
+ f"Are you sure you want to delete template '{name}'?",
680
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
681
+ )
682
+
683
+ if reply == QMessageBox.StandardButton.Yes:
684
+ try:
685
+ filepath = self.selected_template['filepath']
686
+ os.remove(filepath)
687
+ QMessageBox.information(self, "Success", f"Template '{name}' deleted successfully.")
688
+ self.load_user_templates() # Refresh the display
689
+ self.selected_template = None
690
+ self.delete_button.setEnabled(False)
691
+ except Exception as e:
692
+ QMessageBox.critical(self, "Error", f"Failed to delete template: {str(e)}")