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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""アプリ全体(設定・ヘルプ・操作)のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
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
35class PreferencesDialog(QDialog):
36 """環境設定。値の入力と表示だけで、保存は呼び出し側が get_settings() の値で行う。"""
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)
51 outer_layout = QVBoxLayout(self)
52 tabs = QTabWidget()
53 outer_layout.addWidget(tabs)
55 general_tab = QWidget()
56 layout = QVBoxLayout(general_tab)
57 tabs.addTab(general_tab, tr("一般"))
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)
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)
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)
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)
125 layout.addWidget(save_group)
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)
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)
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)
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)
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()
207 layout.addStretch()
209 plugin_tab = QWidget()
210 plugin_tab_layout = QVBoxLayout(plugin_tab)
211 tabs.addTab(plugin_tab, tr("プラグイン"))
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)
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)
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)
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)
258 apply_form_spacing(self)
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
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)
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
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)
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))
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
320 def _refresh_file_association_state(self):
321 from graphica.core.i18n import tr
322 from graphica.gui import file_association
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)
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()
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()
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()))
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)
369 def _on_clear_autosave_dir(self):
370 self._autosave_dir = ""
371 self.autosave_dir_edit.setText("")
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)
384 def _on_clear_default_style_template(self):
385 self._default_style_template = ""
386 self.default_style_template_edit.setText("")
388 def get_default_style_template(self):
389 """新しいタブ・プロジェクトに当てる書式テンプレートのパス。空なら使わない。"""
390 return self._default_style_template
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)
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())
406 def get_theme_color(self):
407 """#RRGGBB(大文字)。"""
408 return self._theme_color
410 def get_wheel_zoom_speed(self):
411 """'slow' / 'normal' / 'fast'。"""
412 return self.wheel_zoom_speed_combo.currentData()
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
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
429 notify.information(
430 self, tr("インストール完了"),
431 tr("プラグイン '{name}' をインストールしました。次回起動時に有効になります。").format(
432 name=installed_name
433 ),
434 )
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())
445class HelpDialog(QDialog):
446 """mathtext の簡単なリファレンス。"""
448 def __init__(self, parent=None):
449 super().__init__(parent)
450 self.setWindowTitle("mathtext クイックリファレンス")
451 self.resize(600, 700)
453 layout = QVBoxLayout(self)
455 text_browser = QTextBrowser()
456 text_browser.setReadOnly(True)
457 text_browser.setOpenExternalLinks(True)
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>
468 <hr>
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>
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>
489 <hr>
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>
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>
521 <hr>
523 <h2>3. よく使う特殊文字・記号 🇬🇷</h2>
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>α (アルファ)</td><td><code>$\alpha$</code></td></tr>
532 <tr><td>β (ベータ)</td><td><code>$\beta$</code></td></tr>
533 <tr><td>γ (ガンマ)</td><td><code>$\gamma$</code></td></tr>
534 <tr><td>Δ (デルタ大文字)</td><td><code>$\Delta$</code></td></tr>
535 <tr><td>δ (デルタ小文字)</td><td><code>$\delta$</code></td></tr>
536 <tr><td>ε (イプシロン)</td><td><code>$\epsilon$</code></td></tr>
537 <tr><td>μ (ミュー)</td><td><code>$\mu$</code></td></tr>
538 <tr><td>π (パイ)</td><td><code>$\pi$</code></td></tr>
539 <tr><td>ρ (ロー)</td><td><code>$\rho$</code></td></tr>
540 <tr><td>Σ (シグマ大文字)</td><td><code>$\Sigma$</code></td></tr>
541 <tr><td>σ (シグマ小文字)</td><td><code>$\sigma$</code></td></tr>
542 <tr><td>τ (タウ)</td><td><code>$\tau$</code></td></tr>
543 <tr><td>Ω (オメガ大文字)</td><td><code>$\Omega$</code></td></tr>
544 <tr><td>ω (オメガ小文字)</td><td><code>$\omega$</code></td></tr>
545 </table>
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>± (プラスマイナス)</td><td><code>$\pm$</code></td></tr>
555 <tr><td>· (中点ドット)</td><td><code>$\cdot$</code></td></tr>
556 <tr><td>× (掛ける)</td><td><code>$\times$</code></td></tr>
557 <tr><td>≈ (ほぼイコール)</td><td><code>$\approx$</code></td></tr>
558 </table>
560 <hr>
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>
568 <hr>
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>∑ (総和)</td><td><code>$\sum_{i=0}^{n}$</code></td></tr>
580 <tr><td>∏ (総乗)</td><td><code>$\prod_{i=1}^{n}$</code></td></tr>
581 <tr><td>∫ (積分)</td><td><code>$\int_{0}^{\infty}$</code></td></tr>
582 <tr><td>∂ (偏微分)</td><td><code>$\partial$</code></td></tr>
583 <tr><td>∇ (ナブラ)</td><td><code>$\nabla$</code></td></tr>
584 <tr><td>∞ (無限大)</td><td><code>$\infty$</code></td></tr>
585 </table>
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>→ (右矢印)</td><td><code>$\rightarrow$</code></td></tr>
594 <tr><td>↔ (両矢印)</td><td><code>$\leftrightarrow$</code></td></tr>
595 <tr><td>≤ (以下)</td><td><code>$\leq$</code></td></tr>
596 <tr><td>≥ (以上)</td><td><code>$\geq$</code></td></tr>
597 <tr><td>≠ (等しくない)</td><td><code>$\neq$</code></td></tr>
598 </table>
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̄ (上線・平均値など)</td><td><code>$\overline{x}$</code></td></tr>
607 <tr><td>x⃗ (ベクトル)</td><td><code>$\vec{x}$</code></td></tr>
608 <tr><td>x̂ (ハット)</td><td><code>$\hat{x}$</code></td></tr>
609 </table>
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)
626 layout.addWidget(text_browser)
628 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
630 button_box.rejected.connect(self.reject)
632 layout.addWidget(button_box)
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 )
644class AboutDialog(QDialog):
645 """バージョンと、使っている OSS のライセンス表記。"""
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
652 self.setWindowTitle(tr("{app} について").format(app=APP_NAME))
653 self.resize(420, 380)
655 layout = QVBoxLayout(self)
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)
665 title_label = QLabel(f"<h2>{APP_NAME}</h2>")
666 title_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
667 layout.addWidget(title_label)
669 version_label = QLabel(f"{tr('バージョン')} {__version__}")
670 version_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
671 layout.addWidget(version_label)
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)
699 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
700 button_box.accepted.connect(self.accept)
701 layout.addWidget(button_box)
704class UpdateDialog(QDialog):
705 """新しい版の知らせ。リリースノートと、配布の形(gui/updater.install_kind)に合った更新の方法を出す。
707 押したものは choice に入る: 'install'(アプリの中で更新)/ 'download'(添付ファイルを開く)/ 'page'(リリースのページ)/ None。
708 """
709 PIP_COMMAND = "python -m pip install -U graphica-plot"
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
715 self.choice = None
716 self.setWindowTitle(tr("新しいバージョンがあります"))
717 self.resize(560, 460)
718 layout = QVBoxLayout(self)
720 tag = update_info.get('tag_name', '')
721 layout.addWidget(QLabel(tr("新しいバージョン {new} が公開されています(今のバージョン: {current})。").format(
722 new=tag, current=current_version)))
724 notes = QTextBrowser()
725 notes.setOpenExternalLinks(True)
726 notes.setMarkdown(update_info.get('body') or tr("(リリースノートはありません)"))
727 layout.addWidget(notes, 1)
729 explanation = QLabel(self._explanation(install_kind, has_asset, tr))
730 explanation.setWordWrap(True)
731 explanation.setObjectName("update_explanation")
732 layout.addWidget(explanation)
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)
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)
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("リリースのページから、お使いの環境に合ったファイルをダウンロードしてください。")
779 def _copy_command(self):
780 from PySide6.QtWidgets import QApplication
781 QApplication.clipboard().setText(self.PIP_COMMAND)
783 def _finish(self, choice):
784 self.choice = choice
785 self.accept()
788class PluginUpdatesDialog(QDialog):
789 """入れたプラグインの新しい版の一覧。チェックしたものを selected_updates() で返す(既定は全部)。"""
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)
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})")
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]
839class WelcomeDialog(QDialog):
840 """初回の案内とスタートアップ画面。操作ガイド、サンプル、最近使ったファイル、書式テンプレートへの入口。
842 ファイルは扱わず、閉じた後に呼び出し側が load_sample_requested / selected_recent_file /
843 load_template_requested / open_project_requested を見て処理する。
844 last_tab_closed は最後のタブを閉じて空のプロジェクトに置き換えた直後(閉じると空のまま始める)。
845 """
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
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 []
861 layout = QVBoxLayout(self)
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)
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)
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)
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
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)
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)
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()
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)
932 def _on_load_sample_clicked(self):
933 self.load_sample_requested = True
934 self.accept()
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()
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()
948 def _on_load_template_clicked(self):
949 self.load_template_requested = True
950 self.accept()
952 def _on_open_project_clicked(self):
953 self.open_project_requested = True
954 self.accept()
957class ShortcutsDialog(QDialog):
958 """キーボードショートカットの一覧。メニューから集めるので、追加や変更で直し忘れない。"""
960 def __init__(self, collect_fn, parent=None):
961 """collect_fn は呼ぶたびにメニューの [(パス, QAction), ...] を集め直す関数。"""
962 super().__init__(parent)
963 self.setWindowTitle("キーボードショートカット一覧")
964 self.resize(420, 380)
966 layout = QVBoxLayout(self)
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)
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)))
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)
994 close_button = QPushButton("閉じる")
995 close_button.clicked.connect(self.reject)
996 layout.addWidget(close_button, alignment=Qt.AlignmentFlag.AlignRight)
999class CommandPaletteDialog(QDialog):
1000 """メニューの項目を検索して実行する(Ctrl+Shift+P)。
1002 属性で持っていない QAction は PySide6 が無効にすることがあるので、一覧にはパスだけを持ち、
1003 実行する直前に collect_fn() で取り直してすぐ trigger() する。
1004 """
1006 def __init__(self, collect_fn, parent=None):
1007 """collect_fn は ShortcutsDialog と同じ。"""
1008 super().__init__(parent)
1009 self.setWindowTitle("コマンドパレット")
1010 self.resize(480, 420)
1012 self._collect_fn = collect_fn
1014 layout = QVBoxLayout(self)
1015 self.search_edit = QLineEdit()
1016 self.search_edit.setPlaceholderText("コマンドを検索...")
1017 layout.addWidget(self.search_edit)
1019 self.list_widget = QListWidget()
1020 layout.addWidget(self.list_widget)
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)
1026 self._update_list("")
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)
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
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)
1075class QuickAccessManagerDialog(QDialog):
1076 """クイックアクセスのピン留めを一覧から選ぶ。チェックの変更はその場で反映する(閉じるボタンだけ)。"""
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
1083 self.setWindowTitle(tr("クイックアクセスの管理"))
1084 self.resize(480, 420)
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 を無視する
1091 layout = QVBoxLayout(self)
1093 info_label = QLabel(tr("チェックした項目がクイックアクセスツールバーに表示されます。"))
1094 info_label.setWordWrap(True)
1095 layout.addWidget(info_label)
1097 self.search_edit = QLineEdit()
1098 self.search_edit.setPlaceholderText(tr("コマンドを検索..."))
1099 layout.addWidget(self.search_edit)
1101 self.list_widget = QListWidget()
1102 layout.addWidget(self.list_widget)
1104 self.search_edit.textChanged.connect(self._update_list)
1105 self.list_widget.itemChanged.connect(self._on_item_changed)
1107 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1108 button_box.rejected.connect(self.accept) # Close は RejectRole なので rejected が出る
1109 layout.addWidget(button_box)
1111 self._update_list("")
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
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)
1143class AutosaveHistoryDialog(QDialog):
1144 """オートセーブの世代を一覧にして、選んだファイルのパスを返す(復元は呼び出し側)。"""
1146 def __init__(self, generations, parent=None):
1147 """generations は (パス, 世代のラベル, 更新日時) の新しい順のリスト。"""
1148 super().__init__(parent)
1149 self.setWindowTitle("自動バックアップ履歴")
1150 self.resize(480, 320)
1152 layout = QVBoxLayout(self)
1153 layout.addWidget(QLabel("復元する世代を選んでください(現在の未保存の変更は失われます):"))
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)
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)
1175 apply_form_spacing(self)
1177 def _on_double_clicked(self, item):
1178 self.history_list.setCurrentItem(item)
1179 self.accept()
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
1186class PluginParamDialog(QDialog):
1187 """プラグインの param_schema から入力欄を作る。
1189 各要素は {"name", "type": "int"|"float"|"str"|"bool"|"choice"} と、任意で "label" / "default" /
1190 "min" / "max" / "choices" / "decimals"(float、既定 4)。
1191 """
1193 _INT_RANGE = (-2_147_483_647, 2_147_483_647) # QSpinBox の範囲
1195 def __init__(self, title, param_schema, parent=None):
1196 super().__init__(parent)
1197 self.setWindowTitle(title)
1198 self._widgets = {} # name -> (type, widget)
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)
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)
1218 apply_form_spacing(self)
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
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