Coverage for paintmaskanimator/actionpanel.py: 44%

434 statements  

« 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.""" 

2 

3import hashlib 

4import importlib.util 

5import json 

6import os 

7import traceback 

8 

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 

14 

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) 

35 

36 

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": '''"""通常のアクションの例: 同一画像から色置換。""" 

43 

44 

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) 

50 

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。""" 

62 

63 

64def register_actions(panel, window): 

65 def repaint_main_line(): 

66 # 通常ボタンのコールバックには引数が渡りません。 

67 window.line_ops.main_line_repaint() 

68 

69 panel.add_action( 

70 "main_line_repaint", 

71 "MainLineRepaint", 

72 repaint_main_line, 

73 tooltip=( 

74 "メイン色・サブ色を線レイヤーへ分離し、" 

75 "抜けた面を周囲の最多色で埋めます。" 

76 ), 

77 ) 

78''', 

79} 

80 

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} 

94 

95# 配るのをやめた組み込みアクション。一字も編集していないものだけ、アクションの 

96# フォルダーから消す(編集したものはユーザーのスクリプトとして残す)。 

97# ブラックチェック(背景以外を黒シルエット表示)はキャンバスの下のボタンへ移した。 

98RETIRED_BUILTIN_HASHES = { 

99 "builtin_silhouette.py": ( 

100 "f4da2226d3af2cb0e7a230f100551be3a0edb8d98b3a3a1e45a486f5157c982e", 

101 "8011b0822863655254bdfeb0eecde99d0d938f9ebeaba3ff050f247368cc0ebb", 

102 # 開発中に一度だけ配った shortcut_action 付きの版。 

103 "24ff57e109610307e9247a43bab5e32ea83e4d12520c96be897a79909f98321d", 

104 ), 

105} 

106 

107# 組み込みアクションのうち、メニューにある操作と同じもの。ショートカットは 

108# メニュー側のものを共有する。アクションのフォルダーは古いバージョンのアプリとも 

109# 共有されるので、スクリプトに新しい引数を書かずにアプリ側で ID から結び付ける。 

110BUILTIN_SHORTCUT_ACTIONS = { 

111 "silhouette": "a_silhouette", 

112 "main_line_repaint": "a_mainline_repaint", 

113} 

114 

115 

116class ActionPanel(QWidget): 

117 """A button list extensible by ``*.py`` files in the user config folder. 

118 

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 """ 

124 

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" 

133 

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() 

141 

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 ) 

155 

156 @staticmethod 

157 def actions_dir(): 

158 path = config.config_dir() / "actions" 

159 path.mkdir(parents=True, exist_ok=True) 

160 return path 

161 

162 def _seed_builtin_scripts(self): 

163 """Write default built-in action scripts into the actions folder once. 

164 

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 

214 

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. 

220 

221 ``callback`` receives the checked state for checkable buttons and no 

222 arguments for regular buttons. 

223 

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)) 

240 

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 ) 

253 

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 

277 

278 def shortcut_entries(self): 

279 """ショートカット設定に並べる ``(表示名, QAction)``(パネルの並び順)。""" 

280 return [(label, action) for label, action, _own in self._shortcut_actions.values()] 

281 

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 } 

289 

290 def button(self, action_id): 

291 return self._buttons.get(action_id) 

292 

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 はボタンの破棄で自然に切れる。 

308 

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 ) 

343 

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)) 

355 

356 

357NEW_SCRIPT_TEMPLATE = '''"""ユーザー定義アクションのひな形。 

358 

359register_actions(panel, window) は読み込み時に1回呼ばれます。 

360window は PaintMaskAnimator のメインウィンドウです。 

361""" 

362 

363 

364def register_actions(panel, window): 

365 def run(): # 通常ボタンのコールバックは引数なし 

366 # ここに実行したい処理を書きます。 

367 window.status_bar.showMessage("カスタムアクションを実行しました", 3000) 

368 

369 panel.add_action( 

370 "my_action.run", # 必須: 重複しないID 

371 "マイアクション", # 必須: ボタンに表示する名前 

372 run, # 必須: run() ではなく関数自体 

373 tooltip="この処理の説明です", # 任意 

374 ) 

375 

376 # 切り替えボタンにする場合: 

377 # def toggle(checked): 

378 # window.status_bar.showMessage(f"状態: {checked}", 3000) 

379 # panel.add_action("my_action.toggle", "切り替え", toggle, checkable=True) 

380''' 

381 

382 

383class _MonacoBridge(QObject): 

384 """Receive editor events from Monaco through Qt WebChannel.""" 

385 

386 def __init__(self, editor): 

387 super().__init__(editor) 

388 self.editor = editor 

389 

390 @Slot(str) 

391 def contentChanged(self, text): # noqa: N802 - called from JavaScript 

392 self.editor._content_changed(text) 

393 

394 @Slot() 

395 def editorReady(self): # noqa: N802 - called from JavaScript 

396 self.editor._editor_ready() 

397 

398 

399class MonacoEditor(QWebEngineView): 

400 """Monaco Editor embedded in Qt, with a small text-widget-compatible API.""" 

401 

402 textChanged = Signal() 

403 

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()))) 

414 

415 def _editor_ready(self): 

416 self._ready = True 

417 self._send_text() 

418 

419 def _content_changed(self, text): 

420 if text == self._text: 

421 return 

422 self._text = text 

423 self.textChanged.emit() 

424 

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})") 

429 

430 def setPlainText(self, text): 

431 self._text = str(text) 

432 self._send_text() 

433 

434 def toPlainText(self): 

435 return self._text 

436 

437 def focus_editor(self): 

438 if self._ready: 

439 self.page().runJavaScript("window.focusEditor()") 

440 

441 

442class ScriptEditorDialog(QDialog): 

443 """In-app editor for the ``*.py`` action scripts in the actions folder.""" 

444 

445 def __init__(self, panel): 

446 super().__init__(panel) 

447 self.panel = panel 

448 self._current_path = None 

449 self._dirty = False 

450 

451 self.setWindowTitle(tr("Pythonアクションの編集")) 

452 self.resize(820, 560) 

453 

454 outer = QVBoxLayout(self) 

455 splitter = QSplitter(Qt.Orientation.Horizontal) 

456 outer.addWidget(splitter, 1) 

457 

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) 

462 

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) 

471 

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) 

493 

494 self._refresh_file_list() 

495 

496 def _scripts(self): 

497 return sorted(self.panel.actions_dir().glob("*.py")) 

498 

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) 

522 

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 

541 

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) 

552 

553 def _on_text_changed(self): 

554 self._dirty = True 

555 

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 

572 

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 

585 

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 ) 

593 

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)) 

627 

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 ) 

633 

634 @staticmethod 

635 def _macos_app_roots(): 

636 return (Path("/Applications"), Path.home() / "Applications") 

637 

638 @classmethod 

639 def _macos_vscode_cli(cls): 

640 """アプリ内の code コマンドを探す。 

641 

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 

652 

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 

663 

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) 

688 

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() 

705 

706 def closeEvent(self, event): 

707 if self._confirm_discard(): 

708 event.accept() 

709 else: 

710 event.ignore()