Coverage for paintmaskanimator/actionpanel.py: 44%
434 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Action panel with built-in and user-defined Python actions."""
3import hashlib
4import importlib.util
5import json
6import os
7import traceback
9from .i18n import tr
10import shiboken6
11from PySide6.QtCore import QCoreApplication, QEvent, QObject, QUrl, Signal, Slot
12from PySide6.QtWebChannel import QWebChannel
13from PySide6.QtWebEngineWidgets import QWebEngineView
15from . import config
16from .utils import app_event_filters_suspended
17import shutil
18import subprocess
19import sys
20from pathlib import Path
21from PySide6.QtCore import Qt
22from PySide6.QtGui import QAction
23from PySide6.QtWidgets import (
24 QDialog,
25 QHBoxLayout,
26 QInputDialog,
27 QListWidget,
28 QListWidgetItem,
29 QMessageBox,
30 QPushButton,
31 QSplitter,
32 QVBoxLayout,
33 QWidget,
34)
37# Built-in actions ship as editable default scripts. They are written into the
38# actions folder on first run (see ``_seed_builtin_scripts``) so users can edit
39# or delete them like any other action. These files also serve as copyable API
40# examples, so callbacks are deliberately named and commented.
41BUILTIN_SCRIPTS = {
42 "builtin_same_image_replacement.py": '''"""通常のアクションの例: 同一画像から色置換。"""
45def register_actions(panel, window):
46 def replace_colors():
47 # window は MainWindow です。既存の操作を呼び出せます。
48 window.line_ops.register_same_image_replacements()
49 window.status_bar.showMessage("同一画像から色置換を実行しました", 3000)
51 panel.add_action(
52 "same_image_replacement",
53 "同一画像から色置換",
54 replace_colors,
55 tooltip=(
56 "同じタイムライン位置にある上のレイヤーと画素配置を比較し、"
57 "一致した色対応をそのまま実画像へ適用します。"
58 ),
59 )
60''',
61 "builtin_main_line_repaint.py": '''"""処理を関数に分ける例: MainLineRepaint。"""
64def register_actions(panel, window):
65 def repaint_main_line():
66 # 通常ボタンのコールバックには引数が渡りません。
67 window.line_ops.main_line_repaint()
69 panel.add_action(
70 "main_line_repaint",
71 "MainLineRepaint",
72 repaint_main_line,
73 tooltip=(
74 "メイン色・サブ色を線レイヤーへ分離し、"
75 "抜けた面を周囲の最多色で埋めます。"
76 ),
77 )
78''',
79}
81# 過去の配布版から一字も編集していないファイルだけ、新しい参考例へ更新します。
82# 版ごとにハッシュが変わるため、各ファイルで複数の旧版を許容します。
83LEGACY_BUILTIN_HASHES = {
84 "builtin_same_image_replacement.py": (
85 "d6116d312479ea89bc167c84886fedbd7f9680b5ba70f0fd25447c2b2f3d9e8b",
86 "a9d08783ade7b38318a77dd5ea4a978834b0f3acaa2dea273a6dc541e17bdfa3",
87 ),
88 "builtin_main_line_repaint.py": (
89 "70855b6e6f7bed6562f9f25b24149a54d66aad9ec55ed7a5264fcd2fed2772d2",
90 "85ac381effc0bc2756560c4ec24a2e49214db8771c41b21bee3f47f9cad869ca",
91 "dacf04ee000490af993430ce64a8f1d84e2a79af6dddd5fc445cc933e02d6941",
92 ),
93}
95# 配るのをやめた組み込みアクション。一字も編集していないものだけ、アクションの
96# フォルダーから消す(編集したものはユーザーのスクリプトとして残す)。
97# ブラックチェック(背景以外を黒シルエット表示)はキャンバスの下のボタンへ移した。
98RETIRED_BUILTIN_HASHES = {
99 "builtin_silhouette.py": (
100 "f4da2226d3af2cb0e7a230f100551be3a0edb8d98b3a3a1e45a486f5157c982e",
101 "8011b0822863655254bdfeb0eecde99d0d938f9ebeaba3ff050f247368cc0ebb",
102 # 開発中に一度だけ配った shortcut_action 付きの版。
103 "24ff57e109610307e9247a43bab5e32ea83e4d12520c96be897a79909f98321d",
104 ),
105}
107# 組み込みアクションのうち、メニューにある操作と同じもの。ショートカットは
108# メニュー側のものを共有する。アクションのフォルダーは古いバージョンのアプリとも
109# 共有されるので、スクリプトに新しい引数を書かずにアプリ側で ID から結び付ける。
110BUILTIN_SHORTCUT_ACTIONS = {
111 "silhouette": "a_silhouette",
112 "main_line_repaint": "a_mainline_repaint",
113}
116class ActionPanel(QWidget):
117 """A button list extensible by ``*.py`` files in the user config folder.
119 A script must define ``register_actions(panel, window)`` and call
120 ``panel.add_action(...)``. Scripts are regular Python and therefore run
121 with the user's permissions; only files deliberately placed in the action
122 directory are loaded.
123 """
125 def __init__(self, main_window):
126 super().__init__()
127 self.main_window = main_window
128 self._buttons = {}
129 self._sources = {}
130 # ボタンごとのショートカット用 QAction(label, action, 自前で作ったか)。
131 self._shortcut_actions = {}
132 self._loading_source = "builtin"
134 layout = QVBoxLayout(self)
135 layout.setContentsMargins(5, 5, 5, 5)
136 layout.setSpacing(4)
137 self.action_layout = QVBoxLayout()
138 self.action_layout.setSpacing(3)
139 layout.addLayout(self.action_layout)
140 layout.addStretch()
142 def open_script_editor(self):
143 # WebEngine を表示している間はアプリ全体のイベントフィルターを外す
144 # (外さないと PySide6 6.11 で落ちる。app_event_filters_suspended 参照)。
145 # フィルターを戻す前に、WebEngine の内部オブジェクトごと破棄しておく。
146 with app_event_filters_suspended():
147 dialog = ScriptEditorDialog(self)
148 try:
149 dialog.exec()
150 finally:
151 shiboken6.delete(dialog)
152 QCoreApplication.sendPostedEvents(
153 None, QEvent.Type.DeferredDelete
154 )
156 @staticmethod
157 def actions_dir():
158 path = config.config_dir() / "actions"
159 path.mkdir(parents=True, exist_ok=True)
160 return path
162 def _seed_builtin_scripts(self):
163 """Write default built-in action scripts into the actions folder once.
165 A marker file records which built-ins have been seeded so user edits
166 are never overwritten and deleted built-ins are not resurrected.
167 """
168 actions_dir = self.actions_dir()
169 marker = actions_dir / ".builtin_seeded.json"
170 try:
171 seeded = set(json.loads(marker.read_text(encoding="utf-8")))
172 except Exception: # noqa: BLE001 - missing or corrupt marker: reseed all
173 seeded = set()
174 changed = False
175 for name, hashes in RETIRED_BUILTIN_HASHES.items():
176 path = actions_dir / name
177 try:
178 # Windows では改行が書き換わって保存されるので、テキストとして読んで比べる。
179 current = path.read_text(encoding="utf-8")
180 if hashlib.sha256(current.encode()).hexdigest() in hashes:
181 path.unlink()
182 except (OSError, UnicodeError):
183 pass
184 for name, content in BUILTIN_SCRIPTS.items():
185 if name in seeded:
186 path = actions_dir / name
187 try:
188 current = path.read_text(encoding="utf-8")
189 digest = hashlib.sha256(current.encode()).hexdigest()
190 if digest in LEGACY_BUILTIN_HASHES.get(name, ()):
191 path.write_text(content, encoding="utf-8")
192 except (OSError, UnicodeError):
193 pass
194 continue
195 path = actions_dir / name
196 if not path.exists(): 196 ↛ 205line 196 didn't jump to line 205 because the condition on line 196 was always true
197 try:
198 path.write_text(content, encoding="utf-8")
199 except Exception as error: # noqa: BLE001 - non-fatal
200 QMessageBox.warning(
201 self, tr("組み込みアクション生成エラー"),
202 f"{name}: {error}",
203 )
204 continue
205 seeded.add(name)
206 changed = True
207 if changed:
208 try:
209 marker.write_text(
210 json.dumps(sorted(seeded)), encoding="utf-8"
211 )
212 except Exception: # noqa: BLE001 - marker is best-effort
213 pass
215 def add_action(
216 self, action_id, label, callback, *, tooltip="", checkable=False,
217 source=None, shortcut_action=None,
218 ):
219 """Add an action button and return it.
221 ``callback`` receives the checked state for checkable buttons and no
222 arguments for regular buttons.
224 Each button gets a ``QAction`` so it can have a keyboard shortcut
225 (ショートカット設定の「アクション」). Pass ``shortcut_action`` when the
226 button does the same thing as an existing menu action: that action's
227 shortcut is used instead of a separate one. Built-in actions are linked
228 this way by ID (``BUILTIN_SHORTCUT_ACTIONS``).
229 """
230 action_id = str(action_id).strip()
231 if not action_id or not callable(callback): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 raise ValueError("action_id and a callable callback are required")
233 if action_id in self._buttons: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise ValueError(f"Action ID already exists: {action_id}")
235 source = source or self._loading_source
236 button = QPushButton(str(label))
237 button.setCheckable(bool(checkable))
238 if tooltip:
239 button.setToolTip(str(tooltip))
241 def invoke(checked=False):
242 try:
243 if checkable: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 callback(bool(checked))
245 else:
246 callback()
247 except Exception as error: # noqa: BLE001 - report extension errors in UI
248 QMessageBox.critical(
249 self,
250 tr("Pythonアクション エラー"),
251 f"{label}\n\n{error}\n\n{traceback.format_exc()}",
252 )
254 button.clicked.connect(invoke)
255 self.action_layout.addWidget(button)
256 self._buttons[action_id] = button
257 self._sources[action_id] = source
258 if shortcut_action is None and action_id in BUILTIN_SHORTCUT_ACTIONS:
259 shortcut_action = getattr(
260 self.main_window, BUILTIN_SHORTCUT_ACTIONS[action_id], None
261 )
262 if isinstance(shortcut_action, QAction):
263 if checkable and shortcut_action.isCheckable(): 263 ↛ 265line 263 didn't jump to line 265 because the condition on line 263 was never true
264 # メニューやショートカットで切り替えたときもボタンの状態を合わせる。
265 button.setChecked(shortcut_action.isChecked())
266 shortcut_action.toggled.connect(button.setChecked)
267 self._shortcut_actions[action_id] = (str(label), shortcut_action, False)
268 else:
269 action = QAction(str(label), self)
270 action.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
271 action.triggered.connect(button.click)
272 # パネルが隠れていても効くよう、メインウィンドウに付ける。
273 owner = self.main_window if isinstance(self.main_window, QWidget) else self
274 owner.addAction(action)
275 self._shortcut_actions[action_id] = (str(label), action, True)
276 return button
278 def shortcut_entries(self):
279 """ショートカット設定に並べる ``(表示名, QAction)``(パネルの並び順)。"""
280 return [(label, action) for label, action, _own in self._shortcut_actions.values()]
282 def own_shortcut_actions(self):
283 """パネルが自分で作った QAction を ``{action_id: QAction}`` で返す。"""
284 return {
285 action_id: action
286 for action_id, (_label, action, own) in self._shortcut_actions.items()
287 if own
288 }
290 def button(self, action_id):
291 return self._buttons.get(action_id)
293 def _remove_python_actions(self):
294 for action_id in list(self._buttons):
295 if self._sources.get(action_id) == "builtin": 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 continue
297 button = self._buttons.pop(action_id)
298 self._sources.pop(action_id, None)
299 self.action_layout.removeWidget(button)
300 button.deleteLater()
301 _label, action, own = self._shortcut_actions.pop(action_id, ("", None, False))
302 if own and action is not None:
303 for widget in (self.main_window, self):
304 if isinstance(widget, QWidget): 304 ↛ 303line 304 didn't jump to line 303 because the condition on line 304 was always true
305 widget.removeAction(action)
306 action.deleteLater()
307 # メニュー側の toggled → button.setChecked はボタンの破棄で自然に切れる。
309 def reload_python_actions(self):
310 self._seed_builtin_scripts()
311 self._remove_python_actions()
312 errors = []
313 for index, path in enumerate(sorted(self.actions_dir().glob("*.py"))):
314 if path.name.startswith("_"): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 continue
316 source = str(path.resolve())
317 try:
318 module_name = f"paintmaskanimator_user_action_{index}_{path.stem}"
319 spec = importlib.util.spec_from_file_location(module_name, path)
320 if spec is None or spec.loader is None: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 raise ImportError(tr("読み込めません: {name}").format(name=path.name))
322 module = importlib.util.module_from_spec(spec)
323 spec.loader.exec_module(module)
324 register = getattr(module, "register_actions", None)
325 if not callable(register): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 raise AttributeError("register_actions(panel, window) が必要です")
327 self._loading_source = source
328 register(self, self.main_window)
329 except Exception as error: # noqa: BLE001 - isolate individual scripts
330 errors.append(f"{path.name}: {error}")
331 finally:
332 self._loading_source = "builtin"
333 # 読み込み直したボタンにも、保存してあるショートカットを付け直す。
334 store = getattr(self.main_window, "shortcut_store", None)
335 if store is not None:
336 store.register_panel_actions()
337 if errors: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 QMessageBox.warning(
339 self,
340 tr("Pythonアクション読み込みエラー"),
341 "\n".join(errors),
342 )
344 def open_actions_folder(self):
345 path = self.actions_dir()
346 try:
347 if sys.platform == "win32":
348 os.startfile(path) # noqa: S606 - user-requested local folder
349 elif sys.platform == "darwin":
350 subprocess.Popen(["open", str(path)])
351 else:
352 subprocess.Popen(["xdg-open", str(path)])
353 except Exception as error: # noqa: BLE001
354 QMessageBox.warning(self, tr("フォルダを開けません"), str(error))
357NEW_SCRIPT_TEMPLATE = '''"""ユーザー定義アクションのひな形。
359register_actions(panel, window) は読み込み時に1回呼ばれます。
360window は PaintMaskAnimator のメインウィンドウです。
361"""
364def register_actions(panel, window):
365 def run(): # 通常ボタンのコールバックは引数なし
366 # ここに実行したい処理を書きます。
367 window.status_bar.showMessage("カスタムアクションを実行しました", 3000)
369 panel.add_action(
370 "my_action.run", # 必須: 重複しないID
371 "マイアクション", # 必須: ボタンに表示する名前
372 run, # 必須: run() ではなく関数自体
373 tooltip="この処理の説明です", # 任意
374 )
376 # 切り替えボタンにする場合:
377 # def toggle(checked):
378 # window.status_bar.showMessage(f"状態: {checked}", 3000)
379 # panel.add_action("my_action.toggle", "切り替え", toggle, checkable=True)
380'''
383class _MonacoBridge(QObject):
384 """Receive editor events from Monaco through Qt WebChannel."""
386 def __init__(self, editor):
387 super().__init__(editor)
388 self.editor = editor
390 @Slot(str)
391 def contentChanged(self, text): # noqa: N802 - called from JavaScript
392 self.editor._content_changed(text)
394 @Slot()
395 def editorReady(self): # noqa: N802 - called from JavaScript
396 self.editor._editor_ready()
399class MonacoEditor(QWebEngineView):
400 """Monaco Editor embedded in Qt, with a small text-widget-compatible API."""
402 textChanged = Signal()
404 def __init__(self, parent=None):
405 super().__init__(parent)
406 self._text = ""
407 self._ready = False
408 self._bridge = _MonacoBridge(self)
409 self._channel = QWebChannel(self.page())
410 self._channel.registerObject("bridge", self._bridge)
411 self.page().setWebChannel(self._channel)
412 html_path = Path(__file__).with_name("assets") / "monaco_editor.html"
413 self.setUrl(QUrl.fromLocalFile(str(html_path.resolve())))
415 def _editor_ready(self):
416 self._ready = True
417 self._send_text()
419 def _content_changed(self, text):
420 if text == self._text:
421 return
422 self._text = text
423 self.textChanged.emit()
425 def _send_text(self):
426 if self._ready:
427 encoded = json.dumps(self._text, ensure_ascii=False)
428 self.page().runJavaScript(f"window.setEditorText({encoded})")
430 def setPlainText(self, text):
431 self._text = str(text)
432 self._send_text()
434 def toPlainText(self):
435 return self._text
437 def focus_editor(self):
438 if self._ready:
439 self.page().runJavaScript("window.focusEditor()")
442class ScriptEditorDialog(QDialog):
443 """In-app editor for the ``*.py`` action scripts in the actions folder."""
445 def __init__(self, panel):
446 super().__init__(panel)
447 self.panel = panel
448 self._current_path = None
449 self._dirty = False
451 self.setWindowTitle(tr("Pythonアクションの編集"))
452 self.resize(820, 560)
454 outer = QVBoxLayout(self)
455 splitter = QSplitter(Qt.Orientation.Horizontal)
456 outer.addWidget(splitter, 1)
458 self.file_list = QListWidget()
459 self.file_list.setMinimumWidth(180)
460 self.file_list.currentItemChanged.connect(self._on_file_selected)
461 splitter.addWidget(self.file_list)
463 right = QWidget()
464 right_layout = QVBoxLayout(right)
465 right_layout.setContentsMargins(0, 0, 0, 0)
466 self.editor = MonacoEditor()
467 self.editor.textChanged.connect(self._on_text_changed)
468 right_layout.addWidget(self.editor, 1)
469 splitter.addWidget(right)
470 splitter.setStretchFactor(1, 1)
472 buttons = QHBoxLayout()
473 self.new_button = QPushButton(tr("新規"))
474 self.delete_button = QPushButton(tr("削除"))
475 self.save_button = QPushButton(tr("保存"))
476 self.save_reload_button = QPushButton(tr("保存して再読み込み"))
477 self.vscode_button = QPushButton(tr("VS Codeで開く"))
478 self.close_button = QPushButton(tr("閉じる"))
479 self.new_button.clicked.connect(self._new_script)
480 self.delete_button.clicked.connect(self._delete_script)
481 self.save_button.clicked.connect(lambda: self._save_current())
482 self.save_reload_button.clicked.connect(self._save_and_reload)
483 self.vscode_button.clicked.connect(self._open_in_vscode)
484 self.close_button.clicked.connect(self.close)
485 buttons.addWidget(self.new_button)
486 buttons.addWidget(self.delete_button)
487 buttons.addStretch()
488 buttons.addWidget(self.save_button)
489 buttons.addWidget(self.save_reload_button)
490 buttons.addWidget(self.vscode_button)
491 buttons.addWidget(self.close_button)
492 outer.addLayout(buttons)
494 self._refresh_file_list()
496 def _scripts(self):
497 return sorted(self.panel.actions_dir().glob("*.py"))
499 def _refresh_file_list(self, select=None):
500 self.file_list.blockSignals(True)
501 self.file_list.clear()
502 target_row = -1
503 for row, path in enumerate(self._scripts()):
504 item = QListWidgetItem(path.name)
505 item.setData(Qt.ItemDataRole.UserRole, str(path))
506 self.file_list.addItem(item)
507 if select is not None and path == select:
508 target_row = row
509 self.file_list.blockSignals(False)
510 if self.file_list.count() == 0:
511 self._current_path = None
512 self.editor.blockSignals(True)
513 self.editor.setPlainText("")
514 self.editor.blockSignals(False)
515 self._dirty = False
516 self.editor.setEnabled(False)
517 return
518 self.editor.setEnabled(True)
519 if target_row < 0:
520 target_row = 0
521 self.file_list.setCurrentRow(target_row)
523 def _on_file_selected(self, current, _previous):
524 if current is None:
525 return
526 if not self._confirm_discard():
527 # Revert selection to the still-loaded file.
528 self._reselect_current()
529 return
530 path = Path(current.data(Qt.ItemDataRole.UserRole))
531 try:
532 text = path.read_text(encoding="utf-8")
533 except Exception as error: # noqa: BLE001
534 QMessageBox.warning(self, tr("読み込めません"), str(error))
535 return
536 self._current_path = path
537 self.editor.blockSignals(True)
538 self.editor.setPlainText(text)
539 self.editor.blockSignals(False)
540 self._dirty = False
542 def _reselect_current(self):
543 if self._current_path is None:
544 return
545 self.file_list.blockSignals(True)
546 for row in range(self.file_list.count()):
547 item = self.file_list.item(row)
548 if Path(item.data(Qt.ItemDataRole.UserRole)) == self._current_path:
549 self.file_list.setCurrentRow(row)
550 break
551 self.file_list.blockSignals(False)
553 def _on_text_changed(self):
554 self._dirty = True
556 def _confirm_discard(self):
557 if not self._dirty or self._current_path is None:
558 return True
559 choice = QMessageBox.question(
560 self,
561 tr("未保存の変更"),
562 tr("{name} の変更を保存しますか?").format(name=self._current_path.name),
563 QMessageBox.StandardButton.Save
564 | QMessageBox.StandardButton.Discard
565 | QMessageBox.StandardButton.Cancel,
566 )
567 if choice == QMessageBox.StandardButton.Cancel:
568 return False
569 if choice == QMessageBox.StandardButton.Save:
570 return self._save_current()
571 return True
573 def _save_current(self):
574 if self._current_path is None:
575 return False
576 try:
577 self._current_path.write_text(
578 self.editor.toPlainText(), encoding="utf-8"
579 )
580 except Exception as error: # noqa: BLE001
581 QMessageBox.warning(self, tr("保存できません"), str(error))
582 return False
583 self._dirty = False
584 return True
586 def _save_and_reload(self):
587 if self._current_path is not None and not self._save_current():
588 return
589 self.panel.reload_python_actions()
590 QMessageBox.information(
591 self, tr("再読み込み"), tr("Pythonアクションを再読み込みしました。")
592 )
594 def _open_in_vscode(self):
595 if self._current_path is None: 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true
596 return
597 if self._dirty and not self._save_current(): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 return
599 executable = shutil.which("code") or shutil.which("code-insiders")
600 if sys.platform == "win32" and executable is None: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 candidates = []
602 for variable in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
603 root = os.environ.get(variable)
604 if root:
605 candidates.extend([
606 Path(root) / "Programs/Microsoft VS Code/Code.exe",
607 Path(root) / "Microsoft VS Code/Code.exe",
608 ])
609 executable = next((str(path) for path in candidates if path.is_file()), None)
610 if sys.platform == "darwin" and executable is None: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 executable = self._macos_vscode_cli()
612 if executable is None and self._open_with_macos_bundle(
613 self._current_path
614 ):
615 return
616 if executable is None: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true
617 QMessageBox.warning(
618 self,
619 tr("VS Codeが見つかりません"),
620 tr("Visual Studio Codeをインストールするか、codeコマンドをPATHへ追加してください。"),
621 )
622 return
623 try:
624 subprocess.Popen([executable, "--goto", str(self._current_path.resolve())])
625 except OSError as error:
626 QMessageBox.warning(self, tr("VS Codeを開けません"), str(error))
628 # macOS の VS Code(安定版・Insiders)のアプリ名とバンドルID。
629 _MACOS_VSCODE_APPS = (
630 ("Visual Studio Code.app", "com.microsoft.VSCode"),
631 ("Visual Studio Code - Insiders.app", "com.microsoft.VSCodeInsiders"),
632 )
634 @staticmethod
635 def _macos_app_roots():
636 return (Path("/Applications"), Path.home() / "Applications")
638 @classmethod
639 def _macos_vscode_cli(cls):
640 """アプリ内の code コマンドを探す。
642 macOS では「シェルコマンド: PATH 内に 'code' をインストール」を
643 実行しない限り code が PATH になく、Finder から起動したアプリは
644 シェルの PATH も引き継がないため。
645 """
646 for root in cls._macos_app_roots():
647 for app_name, _bundle_id in cls._MACOS_VSCODE_APPS:
648 cli = root / app_name / "Contents/Resources/app/bin/code"
649 if cli.is_file():
650 return str(cli)
651 return None
653 def _open_with_macos_bundle(self, script_path):
654 """code コマンドが見つからない場合、バンドルIDで VS Code に開かせる。"""
655 path = str(Path(script_path).resolve())
656 for _app_name, bundle_id in self._MACOS_VSCODE_APPS:
657 result = subprocess.run(
658 ["open", "-b", bundle_id, path], capture_output=True
659 )
660 if result.returncode == 0:
661 return True
662 return False
664 def _new_script(self):
665 if not self._confirm_discard():
666 return
667 name, ok = QInputDialog.getText(
668 self, tr("新規スクリプト"), tr("ファイル名(.py)")
669 )
670 if not ok:
671 return
672 name = name.strip()
673 if not name:
674 return
675 if not name.endswith(".py"):
676 name += ".py"
677 path = self.panel.actions_dir() / name
678 if path.exists():
679 QMessageBox.warning(self, tr("作成できません"), tr("既に存在します: {name}").format(name=name))
680 return
681 try:
682 path.write_text(NEW_SCRIPT_TEMPLATE, encoding="utf-8")
683 except Exception as error: # noqa: BLE001
684 QMessageBox.warning(self, tr("作成できません"), str(error))
685 return
686 self._dirty = False
687 self._refresh_file_list(select=path)
689 def _delete_script(self):
690 if self._current_path is None:
691 return
692 name = self._current_path.name
693 if QMessageBox.question(
694 self, tr("削除"), tr("{name} を削除しますか?").format(name=name)
695 ) != QMessageBox.StandardButton.Yes:
696 return
697 try:
698 self._current_path.unlink()
699 except Exception as error: # noqa: BLE001
700 QMessageBox.warning(self, tr("削除できません"), str(error))
701 return
702 self._current_path = None
703 self._dirty = False
704 self._refresh_file_list()
706 def closeEvent(self, event):
707 if self._confirm_discard():
708 event.accept()
709 else:
710 event.ignore()