Coverage for graphica/gui/dialogs/app.py: 97.1%

781 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""アプリ全体(設定・ヘルプ・操作)のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。""" 

2 

3from PySide6.QtWidgets import ( 

4 QCheckBox, 

5 QComboBox, 

6 QDialog, 

7 QDialogButtonBox, 

8 QDoubleSpinBox, 

9 QFormLayout, 

10 QGroupBox, 

11 QHBoxLayout, 

12 QLabel, 

13 QLineEdit, 

14 QListWidget, 

15 QListWidgetItem, 

16 QPushButton, 

17 QSpinBox, 

18 QTabWidget, 

19 QTableWidget, 

20 QTableWidgetItem, 

21 QTextBrowser, 

22 QVBoxLayout, 

23 QWidget, 

24) 

25from PySide6.QtCore import QEvent, QUrl, Qt 

26from PySide6.QtGui import QDesktopServices, QKeySequence 

27from graphica.gui import notify 

28import os 

29from graphica.gui import dialog_dirs 

30from graphica.gui import icon_utils 

31from graphica.gui.theme import apply_form_spacing 

32from graphica.gui.theme_color import DEFAULT_THEME_COLOR, theme_color_or_default 

33 

34 

35class PreferencesDialog(QDialog): 

36 """環境設定。値の入力と表示だけで、保存は呼び出し側が get_settings() の値で行う。""" 

37 

38 def __init__(self, dark_mode, autosave_minutes, autosave_bounds=(0, 180), parent=None, 

39 current_language=None, autosave_dir="", point_label_max_points=1000, 

40 snap_to_grid_enabled=False, snap_grid_interval_px=10, 

41 plugin_records=None, plugin_registration_errors=None, disabled_plugin_names=None, 

42 default_style_template="", wheel_zoom_speed="normal", theme_color=DEFAULT_THEME_COLOR, 

43 on_check_plugin_updates=None): 

44 """plugin_records=None は一度も読み込んでいない(セーフモードなど)。空のリスト(1つも無い)とは区別して表示する。""" 

45 super().__init__(parent) 

46 from graphica.core.i18n import tr, SUPPORTED_LANGUAGES, get_language 

47 self.setWindowTitle(tr("環境設定")) 

48 # 見出しのチップで各グループの上の余白が広いので、小さいと見切れる 

49 self.resize(480, 520) 

50 

51 outer_layout = QVBoxLayout(self) 

52 tabs = QTabWidget() 

53 outer_layout.addWidget(tabs) 

54 

55 general_tab = QWidget() 

56 layout = QVBoxLayout(general_tab) 

57 tabs.addTab(general_tab, tr("一般")) 

58 

59 appearance_group = QGroupBox(tr("外観")) 

60 appearance_layout = QVBoxLayout(appearance_group) 

61 self.dark_mode_checkbox = QCheckBox(tr("ダークモードを有効にする")) 

62 self.dark_mode_checkbox.setChecked(bool(dark_mode)) 

63 appearance_layout.addWidget(self.dark_mode_checkbox) 

64 # テーマカラー(⚙ のポップアップと同じ設定)。ボタン・選択・枠の色で、グラフの線の色は変わらない 

65 theme_row = QHBoxLayout() 

66 theme_row.addWidget(QLabel(tr("テーマカラー"))) 

67 self.theme_color_button = QPushButton() 

68 self.theme_color_button.setFixedSize(34, 24) 

69 self.theme_color_button.clicked.connect(self._on_pick_theme_color) 

70 self.theme_color_label = QLabel() 

71 reset_theme_button = QPushButton(tr("既定に戻す")) 

72 reset_theme_button.clicked.connect(lambda: self._set_theme_color(DEFAULT_THEME_COLOR)) 

73 theme_row.addWidget(self.theme_color_button) 

74 theme_row.addWidget(self.theme_color_label) 

75 theme_row.addStretch(1) 

76 theme_row.addWidget(reset_theme_button) 

77 appearance_layout.addLayout(theme_row) 

78 self._set_theme_color(theme_color) 

79 layout.addWidget(appearance_group) 

80 

81 # 切り替えは次の起動から(作った画面をその場で訳し直す仕組みは無い) 

82 language_group = QGroupBox(tr("言語")) 

83 language_form = QFormLayout(language_group) 

84 self.language_combo = QComboBox() 

85 self._language_codes = list(SUPPORTED_LANGUAGES.keys()) 

86 self.language_combo.addItems([SUPPORTED_LANGUAGES[code] for code in self._language_codes]) 

87 active_language = current_language or get_language() 

88 if active_language in self._language_codes: 88 ↛ 90line 88 didn't jump to line 90 because the condition on line 88 was always true

89 self.language_combo.setCurrentIndex(self._language_codes.index(active_language)) 

90 language_form.addRow(tr("表示言語"), self.language_combo) 

91 language_note = QLabel(tr("※ 言語の変更は次回起動時に反映されます。")) 

92 language_note.setStyleSheet("font-size: 9pt; color: gray;") 

93 language_note.setWordWrap(True) 

94 language_form.addRow(language_note) 

95 layout.addWidget(language_group) 

96 

97 save_group = QGroupBox(tr("保存")) 

98 save_form = QFormLayout(save_group) 

99 self.autosave_spinbox = QSpinBox() 

100 min_minutes, max_minutes = autosave_bounds 

101 self.autosave_spinbox.setRange(min_minutes, max_minutes) 

102 self.autosave_spinbox.setSuffix(tr(" 分")) 

103 self.autosave_spinbox.setSpecialValueText(tr("無効")) 

104 self.autosave_spinbox.setValue(int(autosave_minutes)) 

105 save_form.addRow(tr("オートセーブ間隔"), self.autosave_spinbox) 

106 

107 # 空なら core.app_paths.get_app_data_dir() 

108 self._autosave_dir = autosave_dir or "" 

109 autosave_dir_row = QHBoxLayout() 

110 self.autosave_dir_edit = QLineEdit(self._autosave_dir) 

111 self.autosave_dir_edit.setReadOnly(True) 

112 from graphica.core.app_paths import get_app_data_dir 

113 self.autosave_dir_edit.setPlaceholderText(tr("(既定: {path})").format(path=get_app_data_dir())) 

114 self.autosave_dir_browse_button = QPushButton(tr("参照...")) 

115 self.autosave_dir_browse_button.setIcon(icon_utils.icon("folder")) 

116 self.autosave_dir_browse_button.clicked.connect(self._on_browse_autosave_dir) 

117 self.autosave_dir_clear_button = QPushButton(tr("既定に戻す")) 

118 self.autosave_dir_clear_button.setIcon(icon_utils.icon("refresh")) 

119 self.autosave_dir_clear_button.clicked.connect(self._on_clear_autosave_dir) 

120 autosave_dir_row.addWidget(self.autosave_dir_edit, 1) 

121 autosave_dir_row.addWidget(self.autosave_dir_browse_button) 

122 autosave_dir_row.addWidget(self.autosave_dir_clear_button) 

123 save_form.addRow(tr("オートセーブ保存先"), autosave_dir_row) 

124 

125 layout.addWidget(save_group) 

126 

127 # 新しいタブ・プロジェクトに最初から当てる書式テンプレート(読み込んだデータセットにも順に当てる) 

128 template_group = QGroupBox(tr("新しいプロジェクト")) 

129 template_form = QFormLayout(template_group) 

130 self._default_style_template = default_style_template or "" 

131 template_row = QHBoxLayout() 

132 self.default_style_template_edit = QLineEdit(self._default_style_template) 

133 self.default_style_template_edit.setReadOnly(True) 

134 self.default_style_template_edit.setPlaceholderText(tr("(使わない)")) 

135 self.default_style_template_browse_button = QPushButton(tr("参照...")) 

136 self.default_style_template_browse_button.setIcon(icon_utils.icon("folder")) 

137 self.default_style_template_browse_button.clicked.connect(self._on_browse_default_style_template) 

138 self.default_style_template_clear_button = QPushButton(tr("使わない")) 

139 self.default_style_template_clear_button.clicked.connect(self._on_clear_default_style_template) 

140 template_row.addWidget(self.default_style_template_edit, 1) 

141 template_row.addWidget(self.default_style_template_browse_button) 

142 template_row.addWidget(self.default_style_template_clear_button) 

143 template_form.addRow(tr("既定の書式テンプレート"), template_row) 

144 layout.addWidget(template_group) 

145 

146 navigation_group = QGroupBox(tr("グラフの操作")) 

147 navigation_form = QFormLayout(navigation_group) 

148 self.wheel_zoom_speed_combo = QComboBox() 

149 for key, label in (("slow", tr("遅い")), ("normal", tr("普通")), ("fast", tr("速い"))): 

150 self.wheel_zoom_speed_combo.addItem(label, key) 

151 found = self.wheel_zoom_speed_combo.findData(wheel_zoom_speed) 

152 self.wheel_zoom_speed_combo.setCurrentIndex(found if found != -1 else 1) 

153 navigation_form.addRow(tr("軸の上のホイールで拡大する速さ"), self.wheel_zoom_speed_combo) 

154 layout.addWidget(navigation_group) 

155 

156 # 点のラベルは点の数だけ annotate するので、多いと固まる。この数を超えたら描かない 

157 performance_group = QGroupBox(tr("パフォーマンス")) 

158 performance_form = QFormLayout(performance_group) 

159 self.point_label_max_spinbox = QSpinBox() 

160 self.point_label_max_spinbox.setRange(10, 1_000_000) 

161 self.point_label_max_spinbox.setSingleStep(100) 

162 self.point_label_max_spinbox.setSuffix(tr(" 点")) 

163 self.point_label_max_spinbox.setValue(int(point_label_max_points)) 

164 self.point_label_max_spinbox.setToolTip(tr( 

165 "データ点にラベルを表示する機能は、データ点数が多いと描画が重くなり、" 

166 "アプリがフリーズする場合があります。この件数を超えるデータセットには、" 

167 "ラベルを有効にしていても自動的に表示しません。" 

168 )) 

169 performance_form.addRow(tr("データ点ラベルの表示上限"), self.point_label_max_spinbox) 

170 layout.addWidget(performance_group) 

171 

172 # 注釈をドラッグで置くとき、ピクセルのグリッドに吸着させる 

173 annotation_group = QGroupBox(tr("注釈")) 

174 annotation_layout = QVBoxLayout(annotation_group) 

175 self.snap_to_grid_checkbox = QCheckBox(tr("スナップ・トゥ・グリッドを有効にする")) 

176 self.snap_to_grid_checkbox.setChecked(bool(snap_to_grid_enabled)) 

177 annotation_layout.addWidget(self.snap_to_grid_checkbox) 

178 annotation_form = QFormLayout() 

179 self.snap_grid_interval_spinbox = QSpinBox() 

180 self.snap_grid_interval_spinbox.setRange(1, 200) 

181 self.snap_grid_interval_spinbox.setSuffix(tr(" px")) 

182 self.snap_grid_interval_spinbox.setValue(int(snap_grid_interval_px)) 

183 annotation_form.addRow(tr("グリッド間隔"), self.snap_grid_interval_spinbox) 

184 annotation_layout.addLayout(annotation_form) 

185 layout.addWidget(annotation_group) 

186 

187 # exe だけを置いた人向け。インストーラーで入れた人はインストーラーが登録・解除する 

188 association_group = QGroupBox(tr("ファイルの関連付け")) 

189 association_layout = QVBoxLayout(association_group) 

190 self.file_association_status_label = QLabel() 

191 self.file_association_status_label.setWordWrap(True) 

192 association_layout.addWidget(self.file_association_status_label) 

193 association_buttons = QHBoxLayout() 

194 self.register_file_association_button = QPushButton(tr(".gra と .graphica を Graphica で開く")) 

195 self.register_file_association_button.clicked.connect(self._on_register_file_association) 

196 self.register_file_association_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

197 association_buttons.addWidget(self.register_file_association_button) 

198 self.unregister_file_association_button = QPushButton(tr("関連付けを解除")) 

199 self.unregister_file_association_button.clicked.connect(self._on_unregister_file_association) 

200 self.unregister_file_association_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

201 association_buttons.addWidget(self.unregister_file_association_button) 

202 association_buttons.addStretch() 

203 association_layout.addLayout(association_buttons) 

204 layout.addWidget(association_group) 

205 self._refresh_file_association_state() 

206 

207 layout.addStretch() 

208 

209 plugin_tab = QWidget() 

210 plugin_tab_layout = QVBoxLayout(plugin_tab) 

211 tabs.addTab(plugin_tab, tr("プラグイン")) 

212 

213 plugin_actions_row = QHBoxLayout() 

214 self.install_plugin_button = QPushButton(tr("プラグインをインストール...")) 

215 self.install_plugin_button.setIcon(icon_utils.icon("download")) 

216 self.install_plugin_button.clicked.connect(self._on_install_plugin) 

217 # OK/キャンセルとは別のすぐ実行するボタンなので、フォーカスの枠を残さない 

218 self.install_plugin_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

219 plugin_actions_row.addWidget(self.install_plugin_button) 

220 self.open_plugins_folder_button = QPushButton(tr("プラグインフォルダを開く")) 

221 self.open_plugins_folder_button.setIcon(icon_utils.icon("folder")) 

222 self.open_plugins_folder_button.clicked.connect(self._on_open_plugins_folder) 

223 self.open_plugins_folder_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

224 plugin_actions_row.addWidget(self.open_plugins_folder_button) 

225 # 確認は呼び出し側(タブ)が別スレッドで行う。結果は環境設定を閉じた後でも知らせる 

226 self.check_plugin_updates_button = QPushButton(tr("更新を確認...")) 

227 self.check_plugin_updates_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

228 self.check_plugin_updates_button.setVisible(on_check_plugin_updates is not None) 

229 if on_check_plugin_updates is not None: 

230 self.check_plugin_updates_button.clicked.connect(on_check_plugin_updates) 

231 plugin_actions_row.addWidget(self.check_plugin_updates_button) 

232 plugin_actions_row.addStretch() 

233 plugin_tab_layout.addLayout(plugin_actions_row) 

234 

235 # チェックの状態は閉じるときに get_disabled_plugin_names() で読まれ、次の起動から効く 

236 loaded_group = QGroupBox(tr("読み込み済みプラグイン")) 

237 loaded_layout = QVBoxLayout(loaded_group) 

238 self.plugin_list = QListWidget() 

239 self._disabled_plugin_names = set(disabled_plugin_names or []) 

240 self._populate_plugin_list(plugin_records, self._disabled_plugin_names) 

241 loaded_layout.addWidget(self.plugin_list) 

242 plugin_tab_layout.addWidget(loaded_group, 1) 

243 

244 # プラグイン自体は読めても、個々の register_xxx が(名前の重複などで)失敗したもの 

245 hook_errors_group = QGroupBox(tr("フック単位の登録エラー")) 

246 hook_errors_layout = QVBoxLayout(hook_errors_group) 

247 self.plugin_hook_errors_list = QListWidget() 

248 self._populate_hook_errors_list(plugin_registration_errors) 

249 hook_errors_layout.addWidget(self.plugin_hook_errors_list) 

250 plugin_tab_layout.addWidget(hook_errors_group) 

251 

252 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | 

253 QDialogButtonBox.StandardButton.Cancel) 

254 button_box.accepted.connect(self.accept) 

255 button_box.rejected.connect(self.reject) 

256 outer_layout.addWidget(button_box) 

257 

258 apply_form_spacing(self) 

259 

260 def _populate_plugin_list(self, plugin_records, disabled_names): 

261 """plugin_records=None なら「読み込んでいない」の1行だけ出す。エラーの行にはチェックを付けない。""" 

262 from graphica.core.i18n import tr 

263 self.plugin_list.clear() 

264 if plugin_records is None: 

265 item = QListWidgetItem(tr("(プラグインは読み込まれていません)")) 

266 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) 

267 self.plugin_list.addItem(item) 

268 return 

269 if not plugin_records: 

270 item = QListWidgetItem(tr("(プラグインはありません)")) 

271 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) 

272 self.plugin_list.addItem(item) 

273 return 

274 

275 for record in plugin_records: 

276 name = record["name"] 

277 info = record.get("info") 

278 error = record.get("error") 

279 disabled = record.get("disabled", False) 

280 

281 if disabled: 

282 label = tr("{name} (無効化中)").format(name=name) 

283 elif error: 

284 label = tr("{name} — エラー: {error}").format(name=name, error=error) 

285 elif info: 

286 label = f"{info.get('name', name)} v{info.get('version', '?')} — {info.get('author', '?')}" 

287 else: 

288 label = name 

289 

290 item = QListWidgetItem(label) 

291 item.setData(Qt.ItemDataRole.UserRole, name) 

292 item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) 

293 item.setCheckState( 

294 Qt.CheckState.Unchecked if name in disabled_names else Qt.CheckState.Checked 

295 ) 

296 self.plugin_list.addItem(item) 

297 

298 def _populate_hook_errors_list(self, plugin_registration_errors): 

299 from graphica.core.i18n import tr 

300 self.plugin_hook_errors_list.clear() 

301 if not plugin_registration_errors: 

302 item = QListWidgetItem(tr("(フック単位の登録エラーはありません)")) 

303 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) 

304 self.plugin_hook_errors_list.addItem(item) 

305 return 

306 for error in plugin_registration_errors: 

307 text = f"[{error.plugin_name}] {error.hook_kind.value}: {error.message}" 

308 self.plugin_hook_errors_list.addItem(QListWidgetItem(text)) 

309 

310 def get_disabled_plugin_names(self): 

311 """チェックを外したプラグイン名の集合(呼び出し側が保存し、次の起動から効く)。""" 

312 disabled = set() 

313 for i in range(self.plugin_list.count()): 

314 item = self.plugin_list.item(i) 

315 name = item.data(Qt.ItemDataRole.UserRole) 

316 if name is not None and item.checkState() == Qt.CheckState.Unchecked: 

317 disabled.add(name) 

318 return disabled 

319 

320 def _refresh_file_association_state(self): 

321 from graphica.core.i18n import tr 

322 from graphica.gui import file_association 

323 

324 supported = file_association.is_supported() 

325 registered = supported and file_association.is_registered() 

326 if not supported: 

327 text = tr("Windows の Graphica.exe から起動したときだけ登録できます。") 

328 elif registered: 

329 text = tr(".gra と .graphica のファイルは、ダブルクリックで Graphica が開きます。") 

330 else: 

331 text = tr("登録すると、.gra と .graphica のファイルをダブルクリックで開けます(この利用者だけ)。") 

332 self.file_association_status_label.setText(text) 

333 self.register_file_association_button.setEnabled(supported and not registered) 

334 self.unregister_file_association_button.setEnabled(registered) 

335 

336 def _on_register_file_association(self): 

337 from graphica.core.i18n import tr 

338 from graphica.gui import file_association 

339 try: 

340 file_association.register() 

341 except OSError as e: 

342 notify.critical(self, tr("ファイルの関連付け"), tr("登録できませんでした:\n{error}").format(error=e)) 

343 self._refresh_file_association_state() 

344 

345 def _on_unregister_file_association(self): 

346 from graphica.core.i18n import tr 

347 from graphica.gui import file_association 

348 try: 

349 file_association.unregister() 

350 except OSError as e: 

351 notify.critical(self, tr("ファイルの関連付け"), tr("解除できませんでした:\n{error}").format(error=e)) 

352 self._refresh_file_association_state() 

353 

354 def _on_open_plugins_folder(self): 

355 from graphica.core.app_paths import get_user_plugins_dir 

356 QDesktopServices.openUrl(QUrl.fromLocalFile(get_user_plugins_dir())) 

357 

358 def _on_browse_autosave_dir(self): 

359 from graphica.core.i18n import tr 

360 from graphica.core.app_paths import get_app_data_dir 

361 # 今の保存先(空なら既定の保存先)から開く。作業フォルダから開くとアプリ本体のフォルダになる 

362 directory = notify.get_existing_directory( 

363 self, tr("オートセーブの保存先を選択"), self._autosave_dir or get_app_data_dir() 

364 ) 

365 if directory: 

366 self._autosave_dir = directory 

367 self.autosave_dir_edit.setText(directory) 

368 

369 def _on_clear_autosave_dir(self): 

370 self._autosave_dir = "" 

371 self.autosave_dir_edit.setText("") 

372 

373 def _on_browse_default_style_template(self): 

374 from graphica.core.i18n import tr 

375 file_path, _ = dialog_dirs.get_open_file_name( 

376 self, dialog_dirs.TEMPLATE, tr("既定の書式テンプレートを選択"), 

377 "Graphica Style Template (*.graphica-style *.json)", 

378 preferred_dir=os.path.dirname(self._default_style_template or "") 

379 ) 

380 if file_path: 380 ↛ exitline 380 didn't return from function '_on_browse_default_style_template' because the condition on line 380 was always true

381 self._default_style_template = file_path 

382 self.default_style_template_edit.setText(file_path) 

383 

384 def _on_clear_default_style_template(self): 

385 self._default_style_template = "" 

386 self.default_style_template_edit.setText("") 

387 

388 def get_default_style_template(self): 

389 """新しいタブ・プロジェクトに当てる書式テンプレートのパス。空なら使わない。""" 

390 return self._default_style_template 

391 

392 def _set_theme_color(self, color): 

393 self._theme_color = theme_color_or_default(color) 

394 self.theme_color_button.setStyleSheet( 

395 f"background: {self._theme_color}; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 6px;") 

396 self.theme_color_label.setText(self._theme_color) 

397 

398 def _on_pick_theme_color(self): 

399 from PySide6.QtGui import QColor 

400 from PySide6.QtWidgets import QColorDialog 

401 from graphica.core.i18n import tr 

402 chosen = QColorDialog.getColor(QColor(self._theme_color), self, tr("テーマカラー")) 

403 if chosen.isValid(): 403 ↛ exitline 403 didn't return from function '_on_pick_theme_color' because the condition on line 403 was always true

404 self._set_theme_color(chosen.name()) 

405 

406 def get_theme_color(self): 

407 """#RRGGBB(大文字)。""" 

408 return self._theme_color 

409 

410 def get_wheel_zoom_speed(self): 

411 """'slow' / 'normal' / 'fast'。""" 

412 return self.wheel_zoom_speed_combo.currentData() 

413 

414 def _on_install_plugin(self): 

415 from graphica.core.i18n import tr 

416 zip_path, _ = dialog_dirs.get_open_file_name( 

417 self, dialog_dirs.OTHER, tr("プラグインをインストール"), tr("Zip files (*.zip)") 

418 ) 

419 if not zip_path: 

420 return 

421 

422 from graphica.core.plugin_install import install_plugin_zip, PluginInstallError 

423 try: 

424 installed_name = install_plugin_zip(zip_path) 

425 except PluginInstallError as e: 

426 notify.critical(self, tr("インストール失敗"), str(e)) 

427 return 

428 

429 notify.information( 

430 self, tr("インストール完了"), 

431 tr("プラグイン '{name}' をインストールしました。次回起動時に有効になります。").format( 

432 name=installed_name 

433 ), 

434 ) 

435 

436 def get_settings(self): 

437 """(ダークモード, オートセーブの間隔(分、0 で無効), 言語, オートセーブ先(空なら既定), 

438 点のラベルの上限, グリッドへの吸着, グリッドの間隔(px))""" 

439 language_code = self._language_codes[self.language_combo.currentIndex()] 

440 return (self.dark_mode_checkbox.isChecked(), self.autosave_spinbox.value(), 

441 language_code, self._autosave_dir, self.point_label_max_spinbox.value(), 

442 self.snap_to_grid_checkbox.isChecked(), self.snap_grid_interval_spinbox.value()) 

443 

444 

445class HelpDialog(QDialog): 

446 """mathtext の簡単なリファレンス。""" 

447 

448 def __init__(self, parent=None): 

449 super().__init__(parent) 

450 self.setWindowTitle("mathtext クイックリファレンス") 

451 self.resize(600, 700) 

452 

453 layout = QVBoxLayout(self) 

454 

455 text_browser = QTextBrowser() 

456 text_browser.setReadOnly(True) 

457 text_browser.setOpenExternalLinks(True) 

458 

459 help_html = r""" 

460 <h1>Matplotlib Mathtext クイックリファレンス</h1> 

461 <p> 

462 <code>mathtext</code> は、プロットラベル(タイトル、軸ラベル、凡例など)に、LaTeXのような数式や特殊文字を簡単に入力するための機能です。 

463 </p> 

464 <p> 

465 テキストボックスに入力する文字列を <b>$</b>(ドルマーク)で囲むと、その中身が数式として解釈されます。 

466 </p> 

467  

468 <hr> 

469  

470 <h2>1. 基本的な書式 (太字・イタリック) 🎨</h2> 

471 <p>ラベルの一部分、または全体を特定の書式に変更できます。</p> 

472 <ul> 

473 <li><b>太字 (Bold)</b>: <code>$\mathbf{...}$</code></li> 

474 <li><b>イタリック (Italic)</b>: <code>$\mathit{...}$</code></li> 

475 <li><b>立体 (Roman/標準)</b>: <code>$\mathrm{...}$</code></li> 

476 </ul> 

477  

478 <h4>✅ 組み合わせの例</h4> 

479 <p>「<b>Speed</b> (<i>v</i>)」と表示したい場合:</p> 

480 <p><b>入力</b>: <code>$\mathbf{Speed}\ \mathit{(v)}$</code></p> 

481 <p> 

482 <ul> 

483 <li><code>\ </code> (バックスラッシュ + スペース) は、数式内で強制的にスペースを入れたい場合に使います。</li> 

484 </ul> 

485 </p> 

486 <p>「Velocity (m/s)」のように、イタリックにしたくない単位(立体にしたい)場合:</p> 

487 <p><b>入力</b>: <code>$\mathrm{Velocity\ (m/s)}$</code></p> 

488 

489 <hr> 

490 

491 <h2>2. 上付き文字 と 下付き文字</h2> 

492 <ul> 

493 <li><b>上付き文字</b>: <code>^</code> (ハット)</li> 

494 <li><b>下付き文字</b>: <code>_</code> (アンダースコア)</li> 

495 </ul> 

496 <p>文字が1文字以上の場合は、<code>{}</code>(中括弧)で囲みます。</p> 

497  

498 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

499 <tr class="header-row"> 

500 <th>表示したい文字</th> 

501 <th>入力するテキスト</th> 

502 </tr> 

503 <tr> 

504 <td>10<sup>3</sup></td> 

505 <td><code>$10^3$</code></td> 

506 </tr> 

507 <tr> 

508 <td>10<sup>-3</sup></td> 

509 <td><code>$10^{-3}$</code></td> 

510 </tr> 

511 <tr> 

512 <td>V<sub>max</sub></td> 

513 <td><code>$V_{\mathrm{max}}$</code></td> 

514 </tr> 

515 <tr> 

516 <td>k<sub>B</sub></td> 

517 <td><code>$k_{\mathrm{B}}$</code></td> 

518 </tr> 

519 </table> 

520  

521 <hr> 

522  

523 <h2>3. よく使う特殊文字・記号 🇬🇷</h2> 

524  

525 <h4>ギリシャ文字</h4> 

526 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

527 <tr class="header-row"> 

528 <th>表示したい文字</th> 

529 <th>入力するテキスト</th> 

530 </tr> 

531 <tr><td>&alpha; (アルファ)</td><td><code>$\alpha$</code></td></tr> 

532 <tr><td>&beta; (ベータ)</td><td><code>$\beta$</code></td></tr> 

533 <tr><td>&gamma; (ガンマ)</td><td><code>$\gamma$</code></td></tr> 

534 <tr><td>&Delta; (デルタ大文字)</td><td><code>$\Delta$</code></td></tr> 

535 <tr><td>&delta; (デルタ小文字)</td><td><code>$\delta$</code></td></tr> 

536 <tr><td>&epsilon; (イプシロン)</td><td><code>$\epsilon$</code></td></tr> 

537 <tr><td>&mu; (ミュー)</td><td><code>$\mu$</code></td></tr> 

538 <tr><td>&pi; (パイ)</td><td><code>$\pi$</code></td></tr> 

539 <tr><td>&rho; (ロー)</td><td><code>$\rho$</code></td></tr> 

540 <tr><td>&Sigma; (シグマ大文字)</td><td><code>$\Sigma$</code></td></tr> 

541 <tr><td>&sigma; (シグマ小文字)</td><td><code>$\sigma$</code></td></tr> 

542 <tr><td>&tau; (タウ)</td><td><code>$\tau$</code></td></tr> 

543 <tr><td>&Omega; (オメガ大文字)</td><td><code>$\Omega$</code></td></tr> 

544 <tr><td>&omega; (オメガ小文字)</td><td><code>$\omega$</code></td></tr> 

545 </table> 

546 

547 <h4>単位・記号</h4> 

548 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

549 <tr class="header-row"> 

550 <th>表示したい文字</th> 

551 <th>入力するテキスト</th> 

552 </tr> 

553 <tr><td>°C (温度)</td><td><code>$^{\circ}$C</code></td></tr> 

554 <tr><td>&pm; (プラスマイナス)</td><td><code>$\pm$</code></td></tr> 

555 <tr><td>&middot; (中点ドット)</td><td><code>$\cdot$</code></td></tr> 

556 <tr><td>&times; (掛ける)</td><td><code>$\times$</code></td></tr> 

557 <tr><td>&approx; (ほぼイコール)</td><td><code>$\approx$</code></td></tr> 

558 </table> 

559 

560 <hr> 

561  

562 <h2>4. 分数 と 根号</h2> 

563 <ul> 

564 <li><b>分数</b>: <code>$\frac{分子}{分母}$</code> (例: <code>$\frac{1}{2}$</code>)</li> 

565 <li><b>平方根 (ルート)</b>: <code>$\sqrt{...}$</code> (例: <code>$\sqrt{2}$</code>)</li> 

566 </ul> 

567 

568 <hr> 

569 

570 <h2>5. 総和・積分・極限などの演算子</h2> 

571 <p> 

572 <code>^</code>・<code>_</code>と組み合わせて、上下に添え字を付けられます。 

573 </p> 

574 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

575 <tr class="header-row"> 

576 <th>表示したい記号</th> 

577 <th>入力するテキスト</th> 

578 </tr> 

579 <tr><td>&sum; (総和)</td><td><code>$\sum_{i=0}^{n}$</code></td></tr> 

580 <tr><td>&prod; (総乗)</td><td><code>$\prod_{i=1}^{n}$</code></td></tr> 

581 <tr><td>&int; (積分)</td><td><code>$\int_{0}^{\infty}$</code></td></tr> 

582 <tr><td>&part; (偏微分)</td><td><code>$\partial$</code></td></tr> 

583 <tr><td>&nabla; (ナブラ)</td><td><code>$\nabla$</code></td></tr> 

584 <tr><td>&infin; (無限大)</td><td><code>$\infty$</code></td></tr> 

585 </table> 

586 

587 <h4>矢印・比較演算子</h4> 

588 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

589 <tr class="header-row"> 

590 <th>表示したい記号</th> 

591 <th>入力するテキスト</th> 

592 </tr> 

593 <tr><td>&rarr; (右矢印)</td><td><code>$\rightarrow$</code></td></tr> 

594 <tr><td>&harr; (両矢印)</td><td><code>$\leftrightarrow$</code></td></tr> 

595 <tr><td>&le; (以下)</td><td><code>$\leq$</code></td></tr> 

596 <tr><td>&ge; (以上)</td><td><code>$\geq$</code></td></tr> 

597 <tr><td>&ne; (等しくない)</td><td><code>$\neq$</code></td></tr> 

598 </table> 

599 

600 <h4>文字の上に記号を付ける</h4> 

601 <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;"> 

602 <tr class="header-row"> 

603 <th>表示したい記号</th> 

604 <th>入力するテキスト</th> 

605 </tr> 

606 <tr><td>x&#772; (上線・平均値など)</td><td><code>$\overline{x}$</code></td></tr> 

607 <tr><td>x&#8407; (ベクトル)</td><td><code>$\vec{x}$</code></td></tr> 

608 <tr><td>x&#770; (ハット)</td><td><code>$\hat{x}$</code></td></tr> 

609 </table> 

610 

611 <p style="color:#888; margin-top: 12px;"> 

612 ※ ここに載っている記法は、いずれもタイトル/軸ラベル欄にそのまま 

613 半角文字で入力すれば動作します(<code>Aa</code>ボタンの装飾メニューに 

614 あるΩボタンからも、よく使うギリシャ文字を選択部分の挿入なしでカーソル 

615 位置に差し込めます)。<br> 

616 なお、これは matplotlib 内蔵の軽量な数式パーサー(mathtext)であり、 

617 <code>\begin{matrix}</code> のような複雑なLaTeX環境や外部LaTeXパッケージ 

618 には対応していません。 

619 </p> 

620 """ 

621 # 見出し行の色は HTML に書かず、テーマの色から注入する(固定の薄いグレーはダークモードで読めない) 

622 self._text_browser = text_browser 

623 self.refresh_theme() 

624 text_browser.setHtml(help_html) 

625 

626 layout.addWidget(text_browser) 

627 

628 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 

629 

630 button_box.rejected.connect(self.reject) 

631 

632 layout.addWidget(button_box) 

633 

634 def refresh_theme(self): 

635 """開いたままダークモードを切り替えられるので、見出し行の色を今のテーマで当て直す。""" 

636 from graphica.gui import theme 

637 _tokens = theme.current_tokens() 

638 self._text_browser.document().setDefaultStyleSheet( 

639 f"tr.header-row {{ background-color: {_tokens['surface_2']}; " 

640 f"color: {_tokens['text_primary']}; }}" 

641 ) 

642 

643 

644class AboutDialog(QDialog): 

645 """バージョンと、使っている OSS のライセンス表記。""" 

646 

647 def __init__(self, parent=None): 

648 super().__init__(parent) 

649 from graphica.core.version import APP_NAME, __version__ 

650 from graphica.core.i18n import tr 

651 

652 self.setWindowTitle(tr("{app} について").format(app=APP_NAME)) 

653 self.resize(420, 380) 

654 

655 layout = QVBoxLayout(self) 

656 

657 if parent is not None: 

658 icon_label = QLabel() 

659 pixmap = parent.windowIcon().pixmap(64, 64) 

660 if not pixmap.isNull(): 660 ↛ 665line 660 didn't jump to line 665 because the condition on line 660 was always true

661 icon_label.setPixmap(pixmap) 

662 icon_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

663 layout.addWidget(icon_label) 

664 

665 title_label = QLabel(f"<h2>{APP_NAME}</h2>") 

666 title_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

667 layout.addWidget(title_label) 

668 

669 version_label = QLabel(f"{tr('バージョン')} {__version__}") 

670 version_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

671 layout.addWidget(version_label) 

672 

673 credits_browser = QTextBrowser() 

674 credits_browser.setOpenExternalLinks(True) 

675 credits_browser.setHtml(r""" 

676 <p>Graphica は、CSV/Excelファイルからデータを読み込み、グラフの作成・編集・ 

677 エクスポートを行うためのデータ可視化ソフトウェアです。</p> 

678 <h3>ライセンス</h3> 

679 <p>Graphica 本体は <b>MIT License</b> で提供しています 

680 (Copyright (c) 2026 STsuruga)。ソースコードは 

681 <a href="https://github.com/STsuruga/Graphica">GitHub</a> で公開しています。</p> 

682 <h3>使用ライブラリ</h3> 

683 <ul> 

684 <li>PySide6 / Qt (LGPL v3)</li> 

685 <li>Matplotlib (PSFベースのライセンス)</li> 

686 <li>NumPy (BSD 3-Clause)</li> 

687 <li>pandas (BSD 3-Clause)</li> 

688 <li>SciPy (BSD 3-Clause)</li> 

689 <li>openpyxl (MIT)</li> 

690 <li>xlrd (BSD)</li> 

691 <li>Tabler Icons (MIT) — アプリ内のアイコン</li> 

692 </ul> 

693 <p>このアプリに同梱している Qt / PySide6 は LGPL v3 です。同梱物の一覧と 

694 再配布時の注意は、配布物に含まれる <code>THIRD_PARTY_LICENSES.md</code> を 

695 参照してください。各ライブラリの詳細な条文は、それぞれの配布元をご確認ください。</p> 

696 """) 

697 layout.addWidget(credits_browser) 

698 

699 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) 

700 button_box.accepted.connect(self.accept) 

701 layout.addWidget(button_box) 

702 

703 

704class UpdateDialog(QDialog): 

705 """新しい版の知らせ。リリースノートと、配布の形(gui/updater.install_kind)に合った更新の方法を出す。 

706 

707 押したものは choice に入る: 'install'(アプリの中で更新)/ 'download'(添付ファイルを開く)/ 'page'(リリースのページ)/ None。 

708 """ 

709 PIP_COMMAND = "python -m pip install -U graphica-plot" 

710 

711 def __init__(self, update_info, current_version, install_kind, has_asset, parent=None): 

712 super().__init__(parent) 

713 from graphica.core.i18n import tr 

714 

715 self.choice = None 

716 self.setWindowTitle(tr("新しいバージョンがあります")) 

717 self.resize(560, 460) 

718 layout = QVBoxLayout(self) 

719 

720 tag = update_info.get('tag_name', '') 

721 layout.addWidget(QLabel(tr("新しいバージョン {new} が公開されています(今のバージョン: {current})。").format( 

722 new=tag, current=current_version))) 

723 

724 notes = QTextBrowser() 

725 notes.setOpenExternalLinks(True) 

726 notes.setMarkdown(update_info.get('body') or tr("(リリースノートはありません)")) 

727 layout.addWidget(notes, 1) 

728 

729 explanation = QLabel(self._explanation(install_kind, has_asset, tr)) 

730 explanation.setWordWrap(True) 

731 explanation.setObjectName("update_explanation") 

732 layout.addWidget(explanation) 

733 

734 if install_kind == 'pip': 

735 command_row = QHBoxLayout() 

736 self.command_edit = QLineEdit(self.PIP_COMMAND) 

737 self.command_edit.setReadOnly(True) 

738 command_row.addWidget(self.command_edit, 1) 

739 copy_button = QPushButton(tr("コピー")) 

740 copy_button.clicked.connect(self._copy_command) 

741 command_row.addWidget(copy_button) 

742 layout.addLayout(command_row) 

743 

744 buttons = QDialogButtonBox() 

745 self.primary_button = None 

746 if has_asset and install_kind == 'installer': 

747 self.primary_button = buttons.addButton(tr("今すぐ更新"), QDialogButtonBox.ButtonRole.AcceptRole) 

748 self.primary_button.clicked.connect(lambda: self._finish('install')) 

749 elif has_asset and install_kind in ('windows_zip', 'macos'): 

750 label = tr("ZIP をダウンロード") if install_kind == 'windows_zip' else tr("macOS 版をダウンロード") 

751 self.primary_button = buttons.addButton(label, QDialogButtonBox.ButtonRole.AcceptRole) 

752 self.primary_button.clicked.connect(lambda: self._finish('download')) 

753 page_button = buttons.addButton(tr("リリースのページを開く"), QDialogButtonBox.ButtonRole.ActionRole) 

754 page_button.clicked.connect(lambda: self._finish('page')) 

755 later_button = buttons.addButton(tr("あとで"), QDialogButtonBox.ButtonRole.RejectRole) 

756 later_button.clicked.connect(self.reject) 

757 if self.primary_button is not None: 

758 self.primary_button.setDefault(True) 

759 layout.addWidget(buttons) 

760 

761 @staticmethod 

762 def _explanation(install_kind, has_asset, tr): 

763 if install_kind == 'installer' and has_asset: 

764 return tr("「今すぐ更新」を押すと、新しいインストーラーをダウンロードして確かめたあと、Graphica を閉じて更新し、" 

765 "自動で起動し直します。設定・プラグイン・自動保存はそのまま残ります。" 

766 "保存していない変更があれば、閉じる前に確認します。") 

767 if install_kind == 'windows_zip' and has_asset: 

768 return tr("ZIP を展開し、今の Graphica のフォルダと置き換えてください。設定とプラグインはユーザーごとの" 

769 "フォルダにあるので残ります。インストーラー版(setup.exe)を使うと、次からはアプリの中で更新できます。") 

770 if install_kind == 'macos' and has_asset: 

771 return tr("ZIP を展開した Graphica.app を、「アプリケーション」フォルダの今の Graphica.app と置き換えてください。" 

772 "設定とプラグインは残ります。署名していないため、初回は右クリックから「開く」を選んでください。") 

773 if install_kind == 'pip': 

774 return tr("pip で入れた Graphica は、次のコマンドで更新できます。") 

775 if install_kind == 'source': 

776 return tr("ソースから実行しています。git pull で最新にしてください。") 

777 return tr("リリースのページから、お使いの環境に合ったファイルをダウンロードしてください。") 

778 

779 def _copy_command(self): 

780 from PySide6.QtWidgets import QApplication 

781 QApplication.clipboard().setText(self.PIP_COMMAND) 

782 

783 def _finish(self, choice): 

784 self.choice = choice 

785 self.accept() 

786 

787 

788class PluginUpdatesDialog(QDialog): 

789 """入れたプラグインの新しい版の一覧。チェックしたものを selected_updates() で返す(既定は全部)。""" 

790 

791 def __init__(self, updates, parent=None): 

792 super().__init__(parent) 

793 from graphica.core.i18n import tr 

794 self.setWindowTitle(tr("プラグインの更新")) 

795 self.resize(520, 360) 

796 self._updates = list(updates) 

797 layout = QVBoxLayout(self) 

798 layout.addWidget(QLabel(tr("新しい版が出ているプラグインがあります。更新するものを選んでください。"))) 

799 self.update_list = QListWidget() 

800 for update in self._updates: 

801 item = QListWidgetItem(f"{update['name']} {update['current']} → {update['latest']}") 

802 item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) 

803 item.setCheckState(Qt.CheckState.Checked) 

804 item.setToolTip(update.get('html_url', '')) 

805 self.update_list.addItem(item) 

806 self.update_list.currentRowChanged.connect(self._show_notes) 

807 layout.addWidget(self.update_list) 

808 self.notes = QTextBrowser() 

809 self.notes.setOpenExternalLinks(True) 

810 layout.addWidget(self.notes, 1) 

811 note = QLabel(tr("更新したプラグインは、Graphica を起動し直すと使われます。")) 

812 note.setWordWrap(True) 

813 layout.addWidget(note) 

814 buttons = QDialogButtonBox() 

815 self.update_button = buttons.addButton(tr("選んだものを更新"), QDialogButtonBox.ButtonRole.AcceptRole) 

816 self.update_button.clicked.connect(self.accept) 

817 self.update_button.setDefault(True) 

818 later_button = buttons.addButton(tr("あとで"), QDialogButtonBox.ButtonRole.RejectRole) 

819 later_button.clicked.connect(self.reject) 

820 layout.addWidget(buttons) 

821 if self._updates: 821 ↛ exitline 821 didn't return from function '__init__' because the condition on line 821 was always true

822 self.update_list.setCurrentRow(0) 

823 

824 def _show_notes(self, row): 

825 from graphica.core.i18n import tr 

826 if not 0 <= row < len(self._updates): 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true

827 self.notes.clear() 

828 return 

829 update = self._updates[row] 

830 body = update.get('body') or tr("(リリースノートはありません)") 

831 url = update.get('html_url', '') 

832 self.notes.setMarkdown(f"**{update['name']} {update['latest']}**\n\n{body}\n\n[{url}]({url})") 

833 

834 def selected_updates(self): 

835 return [update for row, update in enumerate(self._updates) 

836 if self.update_list.item(row).checkState() == Qt.CheckState.Checked] 

837 

838 

839class WelcomeDialog(QDialog): 

840 """初回の案内とスタートアップ画面。操作ガイド、サンプル、最近使ったファイル、書式テンプレートへの入口。 

841 

842 ファイルは扱わず、閉じた後に呼び出し側が load_sample_requested / selected_recent_file / 

843 load_template_requested / open_project_requested を見て処理する。 

844 last_tab_closed は最後のタブを閉じて空のプロジェクトに置き換えた直後(閉じると空のまま始める)。 

845 """ 

846 

847 def __init__(self, parent=None, recent_files=None, last_tab_closed=False): 

848 super().__init__(parent) 

849 import os 

850 from graphica.core.version import APP_NAME 

851 from graphica.core.i18n import tr 

852 

853 self.setWindowTitle(tr("{app} へようこそ").format(app=APP_NAME)) 

854 self.resize(480, 560) 

855 self.load_sample_requested = False 

856 self.selected_recent_file = None 

857 self.load_template_requested = False 

858 self.open_project_requested = False 

859 self._recent_file_paths = list(recent_files) if recent_files else [] 

860 

861 layout = QVBoxLayout(self) 

862 

863 if parent is not None: 

864 icon_label = QLabel() 

865 pixmap = parent.windowIcon().pixmap(64, 64) 

866 if not pixmap.isNull(): 866 ↛ 871line 866 didn't jump to line 871 because the condition on line 866 was always true

867 icon_label.setPixmap(pixmap) 

868 icon_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

869 layout.addWidget(icon_label) 

870 

871 title_label = QLabel(f"<h2>{tr('{app} へようこそ').format(app=APP_NAME)}</h2>") 

872 title_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

873 layout.addWidget(title_label) 

874 

875 guide_browser = QTextBrowser() 

876 guide_browser.setHtml(r""" 

877 <p>{app}は、CSV/Excelファイルからデータを読み込み、グラフの作成・編集・ 

878 エクスポートを行うためのデータ可視化ソフトウェアです。</p> 

879 <h3>はじめの一歩</h3> 

880 <ol> 

881 <li>画面下部の<b>「データ追加」</b>ボタン、または「ファイル」メニューの「データファイルを開く」でCSV/Excelファイルを読み込む 

882 (下の「サンプルデータを開く」からすぐに試すこともできます)</li> 

883 <li>X軸・Y軸に使う列を選択する</li> 

884 <li>右側の「データセットのプロパティ」パネルで色・線種・マーカーなどを調整する</li> 

885 <li>「曲線フィット」「ピーク検出」などの解析機能を試す</li> 

886 <li>「ファイル」メニューの「エクスポート」▸「名前を付けてエクスポート」で画像として保存する</li> 

887 </ol> 

888 <p>この画面は初回起動時に表示されます。「ファイル」メニューの 

889 「スタートアップ画面...」からいつでも開けます。</p> 

890 """.format(app=APP_NAME)) 

891 guide_browser.setMaximumHeight(180) 

892 layout.addWidget(guide_browser) 

893 

894 # 履歴が無ければ(初回など)節ごと出さない 

895 if self._recent_file_paths: 

896 recent_group = QGroupBox(tr("最近使ったファイル")) 

897 recent_layout = QVBoxLayout(recent_group) 

898 self.recent_list = QListWidget() 

899 for path in self._recent_file_paths: 

900 item = QListWidgetItem(os.path.basename(path)) 

901 item.setToolTip(path) 

902 self.recent_list.addItem(item) 

903 self.recent_list.itemDoubleClicked.connect(self._on_recent_item_double_clicked) 

904 recent_layout.addWidget(self.recent_list) 

905 open_recent_button = QPushButton(tr("選択したファイルを開く")) 

906 open_recent_button.clicked.connect(self._on_open_recent_clicked) 

907 recent_layout.addWidget(open_recent_button) 

908 layout.addWidget(recent_group) 

909 else: 

910 self.recent_list = None 

911 

912 button_row = QHBoxLayout() 

913 self.open_project_button = QPushButton(tr("プロジェクトを開く...")) 

914 self.open_project_button.clicked.connect(self._on_open_project_clicked) 

915 button_row.addWidget(self.open_project_button) 

916 

917 self.load_sample_button = QPushButton(tr("サンプルデータを開く")) 

918 self.load_sample_button.clicked.connect(self._on_load_sample_clicked) 

919 button_row.addWidget(self.load_sample_button) 

920 

921 self.load_template_button = QPushButton(tr("書式テンプレートを開く...")) 

922 self.load_template_button.clicked.connect(self._on_load_template_clicked) 

923 button_row.addWidget(self.load_template_button) 

924 button_row.addStretch() 

925 

926 close_button = QPushButton(tr("新しいプロジェクトで始める") if last_tab_closed else tr("閉じる")) 

927 close_button.setDefault(last_tab_closed) 

928 close_button.clicked.connect(self.accept) 

929 button_row.addWidget(close_button) 

930 layout.addLayout(button_row) 

931 

932 def _on_load_sample_clicked(self): 

933 self.load_sample_requested = True 

934 self.accept() 

935 

936 def _on_open_recent_clicked(self): 

937 row = self.recent_list.currentRow() if self.recent_list is not None else -1 

938 if row < 0: 

939 return 

940 self.selected_recent_file = self._recent_file_paths[row] 

941 self.accept() 

942 

943 def _on_recent_item_double_clicked(self, item): 

944 row = self.recent_list.row(item) 

945 self.selected_recent_file = self._recent_file_paths[row] 

946 self.accept() 

947 

948 def _on_load_template_clicked(self): 

949 self.load_template_requested = True 

950 self.accept() 

951 

952 def _on_open_project_clicked(self): 

953 self.open_project_requested = True 

954 self.accept() 

955 

956 

957class ShortcutsDialog(QDialog): 

958 """キーボードショートカットの一覧。メニューから集めるので、追加や変更で直し忘れない。""" 

959 

960 def __init__(self, collect_fn, parent=None): 

961 """collect_fn は呼ぶたびにメニューの [(パス, QAction), ...] を集め直す関数。""" 

962 super().__init__(parent) 

963 self.setWindowTitle("キーボードショートカット一覧") 

964 self.resize(420, 380) 

965 

966 layout = QVBoxLayout(self) 

967 

968 table = QTableWidget() 

969 table.setColumnCount(2) 

970 table.setHorizontalHeaderLabels(["操作", "ショートカット"]) 

971 table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 

972 table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) 

973 table.verticalHeader().setVisible(False) 

974 

975 rows = [] 

976 for path, action in collect_fn(): 

977 # 属性で持っていない QAction は、集めた直後でも PySide6 が無効にしていることがある 

978 try: 

979 shortcut = action.shortcut() 

980 except RuntimeError: 

981 continue 

982 if shortcut.isEmpty(): 

983 continue 

984 rows.append((" > ".join(path), shortcut.toString(QKeySequence.SequenceFormat.NativeText))) 

985 

986 table.setRowCount(len(rows)) 

987 for row_idx, (label, shortcut_text) in enumerate(rows): 

988 table.setItem(row_idx, 0, QTableWidgetItem(label)) 

989 table.setItem(row_idx, 1, QTableWidgetItem(shortcut_text)) 

990 table.resizeColumnsToContents() 

991 table.horizontalHeader().setStretchLastSection(True) 

992 layout.addWidget(table) 

993 

994 close_button = QPushButton("閉じる") 

995 close_button.clicked.connect(self.reject) 

996 layout.addWidget(close_button, alignment=Qt.AlignmentFlag.AlignRight) 

997 

998 

999class CommandPaletteDialog(QDialog): 

1000 """メニューの項目を検索して実行する(Ctrl+Shift+P)。 

1001 

1002 属性で持っていない QAction は PySide6 が無効にすることがあるので、一覧にはパスだけを持ち、 

1003 実行する直前に collect_fn() で取り直してすぐ trigger() する。 

1004 """ 

1005 

1006 def __init__(self, collect_fn, parent=None): 

1007 """collect_fn は ShortcutsDialog と同じ。""" 

1008 super().__init__(parent) 

1009 self.setWindowTitle("コマンドパレット") 

1010 self.resize(480, 420) 

1011 

1012 self._collect_fn = collect_fn 

1013 

1014 layout = QVBoxLayout(self) 

1015 self.search_edit = QLineEdit() 

1016 self.search_edit.setPlaceholderText("コマンドを検索...") 

1017 layout.addWidget(self.search_edit) 

1018 

1019 self.list_widget = QListWidget() 

1020 layout.addWidget(self.list_widget) 

1021 

1022 self.search_edit.textChanged.connect(self._update_list) 

1023 self.list_widget.itemActivated.connect(self._on_item_activated) 

1024 self.search_edit.installEventFilter(self) 

1025 

1026 self._update_list("") 

1027 

1028 def _update_list(self, text): 

1029 query = text.strip().lower() 

1030 self.list_widget.clear() 

1031 for path, action in self._collect_fn(): 

1032 if not action.isEnabled(): 

1033 continue 

1034 label = " > ".join(path) 

1035 if query and query not in label.lower(): 

1036 continue 

1037 item = QListWidgetItem(label) 

1038 if action.isCheckable(): 

1039 item.setText(f"{'✓' if action.isChecked() else ' '} {label}") 

1040 item.setData(Qt.ItemDataRole.UserRole, path) 

1041 self.list_widget.addItem(item) 

1042 if self.list_widget.count() > 0: 1042 ↛ exitline 1042 didn't return from function '_update_list' because the condition on line 1042 was always true

1043 self.list_widget.setCurrentRow(0) 

1044 

1045 def _on_item_activated(self, item): 

1046 target_path = item.data(Qt.ItemDataRole.UserRole) 

1047 self.accept() 

1048 if target_path is None: 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true

1049 return 

1050 for path, action in self._collect_fn(): 1050 ↛ exitline 1050 didn't return from function '_on_item_activated' because the loop on line 1050 didn't complete

1051 if path == target_path: 1051 ↛ 1050line 1051 didn't jump to line 1050 because the condition on line 1051 was always true

1052 action.trigger() 

1053 return 

1054 

1055 def eventFilter(self, obj, event): 

1056 """検索欄にフォーカスがあるまま、上下キーで選択を動かし Enter で実行する。""" 

1057 if obj is self.search_edit and event.type() == QEvent.Type.KeyPress: 

1058 key = event.key() 

1059 if key == Qt.Key.Key_Down: 

1060 row = min(self.list_widget.currentRow() + 1, self.list_widget.count() - 1) 

1061 self.list_widget.setCurrentRow(row) 

1062 return True 

1063 elif key == Qt.Key.Key_Up: 

1064 row = max(self.list_widget.currentRow() - 1, 0) 

1065 self.list_widget.setCurrentRow(row) 

1066 return True 

1067 elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 1067 ↛ 1072line 1067 didn't jump to line 1072 because the condition on line 1067 was always true

1068 current = self.list_widget.currentItem() 

1069 if current is not None: 1069 ↛ 1071line 1069 didn't jump to line 1071 because the condition on line 1069 was always true

1070 self._on_item_activated(current) 

1071 return True 

1072 return super().eventFilter(obj, event) 

1073 

1074 

1075class QuickAccessManagerDialog(QDialog): 

1076 """クイックアクセスのピン留めを一覧から選ぶ。チェックの変更はその場で反映する(閉じるボタンだけ)。""" 

1077 

1078 def __init__(self, collect_fn, is_pinned_fn, toggle_fn, parent=None): 

1079 """collect_fn は CommandPaletteDialog と同じ。is_pinned_fn(id) -> bool、toggle_fn(id, path, checked)。""" 

1080 super().__init__(parent) 

1081 from graphica.core.i18n import tr 

1082 

1083 self.setWindowTitle(tr("クイックアクセスの管理")) 

1084 self.resize(480, 420) 

1085 

1086 self._collect_fn = collect_fn 

1087 self._is_pinned_fn = is_pinned_fn 

1088 self._toggle_fn = toggle_fn 

1089 self._updating = False # _update_list() がチェックを入れている間は itemChanged を無視する 

1090 

1091 layout = QVBoxLayout(self) 

1092 

1093 info_label = QLabel(tr("チェックした項目がクイックアクセスツールバーに表示されます。")) 

1094 info_label.setWordWrap(True) 

1095 layout.addWidget(info_label) 

1096 

1097 self.search_edit = QLineEdit() 

1098 self.search_edit.setPlaceholderText(tr("コマンドを検索...")) 

1099 layout.addWidget(self.search_edit) 

1100 

1101 self.list_widget = QListWidget() 

1102 layout.addWidget(self.list_widget) 

1103 

1104 self.search_edit.textChanged.connect(self._update_list) 

1105 self.list_widget.itemChanged.connect(self._on_item_changed) 

1106 

1107 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 

1108 button_box.rejected.connect(self.accept) # Close は RejectRole なので rejected が出る 

1109 layout.addWidget(button_box) 

1110 

1111 self._update_list("") 

1112 

1113 def _update_list(self, text): 

1114 query = text.strip().lower() 

1115 self._updating = True 

1116 try: 

1117 self.list_widget.clear() 

1118 for path, action in self._collect_fn(): 

1119 if action.isSeparator(): 

1120 continue 

1121 label = " > ".join(path) 

1122 if query and query not in label.lower(): 

1123 continue 

1124 ident = " > ".join(path) 

1125 item = QListWidgetItem(label) 

1126 item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) 

1127 item.setData(Qt.ItemDataRole.UserRole, (ident, path)) 

1128 item.setCheckState( 

1129 Qt.CheckState.Checked if self._is_pinned_fn(ident) else Qt.CheckState.Unchecked 

1130 ) 

1131 self.list_widget.addItem(item) 

1132 finally: 

1133 self._updating = False 

1134 

1135 def _on_item_changed(self, item): 

1136 if self._updating: 

1137 return 

1138 ident, path = item.data(Qt.ItemDataRole.UserRole) 

1139 checked = item.checkState() == Qt.CheckState.Checked 

1140 self._toggle_fn(ident, path, checked) 

1141 

1142 

1143class AutosaveHistoryDialog(QDialog): 

1144 """オートセーブの世代を一覧にして、選んだファイルのパスを返す(復元は呼び出し側)。""" 

1145 

1146 def __init__(self, generations, parent=None): 

1147 """generations は (パス, 世代のラベル, 更新日時) の新しい順のリスト。""" 

1148 super().__init__(parent) 

1149 self.setWindowTitle("自動バックアップ履歴") 

1150 self.resize(480, 320) 

1151 

1152 layout = QVBoxLayout(self) 

1153 layout.addWidget(QLabel("復元する世代を選んでください(現在の未保存の変更は失われます):")) 

1154 

1155 self.history_list = QListWidget() 

1156 for path, label, mtime_text in generations: 

1157 item = QListWidgetItem(f"{label} — {mtime_text}") 

1158 item.setData(Qt.ItemDataRole.UserRole, path) 

1159 item.setToolTip(path) 

1160 self.history_list.addItem(item) 

1161 self.history_list.itemDoubleClicked.connect(self._on_double_clicked) 

1162 layout.addWidget(self.history_list) 

1163 

1164 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | 

1165 QDialogButtonBox.StandardButton.Cancel) 

1166 button_box.accepted.connect(self.accept) 

1167 button_box.rejected.connect(self.reject) 

1168 self.ok_button = button_box.button(QDialogButtonBox.StandardButton.Ok) 

1169 self.ok_button.setEnabled(False) 

1170 self.history_list.currentItemChanged.connect( 

1171 lambda current, _previous: self.ok_button.setEnabled(current is not None) 

1172 ) 

1173 layout.addWidget(button_box) 

1174 

1175 apply_form_spacing(self) 

1176 

1177 def _on_double_clicked(self, item): 

1178 self.history_list.setCurrentItem(item) 

1179 self.accept() 

1180 

1181 def get_selected_path(self): 

1182 item = self.history_list.currentItem() 

1183 return item.data(Qt.ItemDataRole.UserRole) if item is not None else None 

1184 

1185 

1186class PluginParamDialog(QDialog): 

1187 """プラグインの param_schema から入力欄を作る。 

1188 

1189 各要素は {"name", "type": "int"|"float"|"str"|"bool"|"choice"} と、任意で "label" / "default" / 

1190 "min" / "max" / "choices" / "decimals"(float、既定 4)。 

1191 """ 

1192 

1193 _INT_RANGE = (-2_147_483_647, 2_147_483_647) # QSpinBox の範囲 

1194 

1195 def __init__(self, title, param_schema, parent=None): 

1196 super().__init__(parent) 

1197 self.setWindowTitle(title) 

1198 self._widgets = {} # name -> (type, widget) 

1199 

1200 layout = QVBoxLayout(self) 

1201 form = QFormLayout() 

1202 for spec in param_schema: 

1203 name = spec["name"] 

1204 label = spec.get("label", name) 

1205 ptype = spec.get("type", "str") 

1206 default = spec.get("default") 

1207 widget = self._build_widget(ptype, spec, default) 

1208 self._widgets[name] = (ptype, widget) 

1209 form.addRow(label, widget) 

1210 layout.addLayout(form) 

1211 

1212 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | 

1213 QDialogButtonBox.StandardButton.Cancel) 

1214 button_box.accepted.connect(self.accept) 

1215 button_box.rejected.connect(self.reject) 

1216 layout.addWidget(button_box) 

1217 

1218 apply_form_spacing(self) 

1219 

1220 def _build_widget(self, ptype, spec, default): 

1221 if ptype == "int": 

1222 widget = QSpinBox() 

1223 widget.setRange(spec.get("min", self._INT_RANGE[0]), spec.get("max", self._INT_RANGE[1])) 

1224 widget.setValue(int(default) if default is not None else 0) 

1225 return widget 

1226 if ptype == "float": 

1227 widget = QDoubleSpinBox() 

1228 widget.setDecimals(spec.get("decimals", 4)) 

1229 widget.setRange(spec.get("min", -1e12), spec.get("max", 1e12)) 

1230 widget.setValue(float(default) if default is not None else 0.0) 

1231 return widget 

1232 if ptype == "bool": 

1233 widget = QCheckBox() 

1234 widget.setChecked(bool(default)) 

1235 return widget 

1236 if ptype == "choice": 

1237 widget = QComboBox() 

1238 widget.addItems([str(c) for c in spec.get("choices", [])]) 

1239 if default is not None: 1239 ↛ 1241line 1239 didn't jump to line 1241 because the condition on line 1239 was always true

1240 widget.setCurrentText(str(default)) 

1241 return widget 

1242 # "str" と未知の型は文字の欄 

1243 widget = QLineEdit() 

1244 if default is not None: 1244 ↛ 1246line 1244 didn't jump to line 1246 because the condition on line 1244 was always true

1245 widget.setText(str(default)) 

1246 return widget 

1247 

1248 def get_values(self): 

1249 """{パラメータ名: 値}。型は param_schema の type に従う。""" 

1250 values = {} 

1251 for name, (ptype, widget) in self._widgets.items(): 

1252 if ptype in ("int", "float"): 

1253 values[name] = widget.value() 

1254 elif ptype == "bool": 

1255 values[name] = widget.isChecked() 

1256 elif ptype == "choice": 

1257 values[name] = widget.currentText() 

1258 else: 

1259 values[name] = widget.text() 

1260 return values