Coverage for graphica/gui/menu_bar.py: 82.5%
184 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"""
2メニューバーの中身の一覧表と、それを組み立てる関数。
4項目は PlotterApp のメソッド名で呼び先を指す。コマンドパレット・クイックアクセス・ショートカット一覧は
5メニューを辿って項目を集め、そのパス(「ファイル > エクスポート > 印刷(&R)...」)を識別子として保存するので、
6文字列を変えると保存済みのピン留めが外れる。
8PySide6 は、Python 側の参照が無くなった QMenu・menuAction・QAction を後から回収することがある
9(メニューを辿ったとたん "already deleted" になる)。組み立てたものはすべて app._menu_keepalive に持たせる。
10"""
11from dataclasses import dataclass
12from typing import Callable, Optional
14from PySide6.QtGui import QAction, QKeySequence
15from PySide6.QtWidgets import QApplication
17from graphica.core.i18n import tr
18from graphica.core.version import APP_NAME
19from graphica.gui.theme import apply_theme
22@dataclass(frozen=True)
23class Item:
24 """
25 メニューの1項目。slot は triggered(checkable なら toggled)に繋ぐメソッド名。
26 attr を指定すると app にその名前で持たせる(ほかの場所から有効/無効やチェックを変えるもの)。
27 checked は初めのチェック状態を持つ app の属性の点区切りの名前。after は作った後に呼ぶメソッド名。
28 """
29 text: str
30 slot: str
31 shortcut: object = None
32 attr: Optional[str] = None
33 checked: Optional[str] = None
34 after: Optional[str] = None
37@dataclass(frozen=True)
38class Submenu:
39 """on_show は開くたびに中身を作り直すメソッド名。fill_now なら作った時点でも一度呼ぶ。"""
40 text: str
41 items: tuple = ()
42 attr: Optional[str] = None
43 action_attr: Optional[str] = None
44 on_show: Optional[str] = None
45 fill_now: bool = False
46 after: Optional[str] = None
49@dataclass(frozen=True)
50class DockToggle:
51 """ドックの表示切り替え(ドック自身の toggleViewAction)。dock は app からの点区切りの名前。"""
52 dock: str
53 text: str
56@dataclass(frozen=True)
57class Call:
58 """メニューとは別に、この位置で呼ぶ app のメソッド(組み立ての順番に意味があるもの)。"""
59 method: str
62@dataclass(frozen=True)
63class Custom:
64 """表で書けない項目。build(app, menu, keep) が menu に足す。"""
65 build: Callable
68SEPARATOR = object()
71@dataclass(frozen=True)
72class TopMenu:
73 text: str
74 attr: str
75 items: tuple = ()
76 action_attr: Optional[str] = None
77 on_show: Optional[str] = None
78 fill_now: bool = False
81def _add_undo_redo(app, menu, keep):
82 undo_action = app.undo_stack.createUndoAction(app, tr("元に戻す"))
83 undo_action.setShortcut(QKeySequence.StandardKey.Undo)
84 menu.addAction(undo_action)
85 redo_action = app.undo_stack.createRedoAction(app, tr("やり直し"))
86 redo_action.setShortcut(QKeySequence.StandardKey.Redo)
87 menu.addAction(redo_action)
88 keep.extend([undo_action, redo_action])
91def _add_command_palette(app, menu, keep):
92 # ウィンドウにも足して、メニューを開いていなくてもショートカットが効くようにする
93 app.command_palette_action = QAction(tr("コマンドパレット(&K)..."), app)
94 app.command_palette_action.setShortcut(QKeySequence("Ctrl+Shift+P"))
95 app.command_palette_action.triggered.connect(app._on_show_command_palette)
96 app.addAction(app.command_palette_action)
97 menu.addAction(app.command_palette_action)
100def _add_bottom_panel_items(app, menu, keep):
101 """下のパネルのタブを開く項目(シンプルならアドバンスドに切り替える)。"""
102 from graphica.gui.shell.bottom_panel import BOTTOM_PAGES
103 app.bottom_panel_actions = {}
104 for key, title, _icon in BOTTOM_PAGES:
105 action = menu.addAction(tr(title))
106 action.triggered.connect(lambda _checked=False, k=key: app.show_bottom_page(k))
107 keep.append(action)
108 app.bottom_panel_actions[key] = action
111FILE_MENU = TopMenu("ファイル(&F)", "_file_menu", (
112 # Ctrl+O は「プロジェクトを開く」のもの
113 Item("データファイルを開く(&D)...", "_on_add_dataset", attr="open_data_file_action"),
114 SEPARATOR,
115 Item("プロジェクトを開く(&O)...", "_on_load_project", QKeySequence.StandardKey.Open, attr="open_project_action"),
116 Item("上書き保存(&P)", "_on_save_project", QKeySequence.StandardKey.Save, attr="save_project_action"),
117 Item("名前を付けて保存(&A)...", "_on_save_project_as", QKeySequence.StandardKey.SaveAs,
118 attr="save_project_as_action"),
119 Item("クリップボードから貼り付け(&V)...", "_on_paste_data_from_clipboard"),
120 Item("フォルダから一括インポート(&F)...", "_on_import_folder"),
121 Submenu("最近使ったファイル", attr="recent_files_menu", action_attr="_recent_files_menu_action",
122 after="_update_recent_files_menu"),
123 Item("スタートアップ画面(&W)...", "_on_show_startup_screen"),
124 SEPARATOR,
125 Item("書式テンプレートを保存(&T)...", "_on_save_plot_template"),
126 Item("書式テンプレートを適用(&A)...", "_on_load_plot_template"),
127 SEPARATOR,
128 Submenu("エクスポート", attr="_export_menu", action_attr="_export_menu_action", items=(
129 # Ctrl+Shift+S は「名前を付けて保存」のもの(同じキーが2つあると Qt はどちらも発火させない)
130 Item("名前を付けてエクスポート(&S)...", "_on_export_plot", attr="save_action"),
131 # Ctrl+C は文字のコピーとぶつかるので割り当てない
132 Item("グラフをコピー(&C)", "_on_copy_plot_to_clipboard"),
133 Item("印刷(&R)...", "_on_print_plot", QKeySequence.StandardKey.Print, attr="print_action"),
134 Item("バッチエクスポート(&B)...", "_on_batch_export"),
135 Item("Pythonスクリプトとしてエクスポート...", "_on_export_python_script"),
136 Item("LaTeX/Word用キャプションを生成...", "_on_generate_caption"),
137 Item("実験レポートを生成 (HTML/PDF)...", "_on_generate_report"),
138 )),
139 SEPARATOR,
140 Item("オートセーブ間隔を設定(&I)...", "_on_configure_autosave_interval", attr="autosave_interval_action",
141 after="_update_autosave_menu_text"),
142 Item("自動バックアップ履歴から復元(&H)...", "_on_show_autosave_history"),
143 SEPARATOR,
144 Item("設定・スタイルをエクスポート(&X)...", "_on_export_settings"),
145 Item("設定・スタイルをインポート(&M)...", "_on_import_settings"),
146))
148EDIT_MENU = TopMenu("編集(&E)", "_edit_menu", (
149 Custom(_add_undo_redo),
150 SEPARATOR,
151 Item("環境設定(&P)...", "_on_show_preferences"),
152 Custom(_add_command_palette),
153))
155# 右クリックと同じ中身を開くたびに作る(片方だけに項目を足さないため)
156DATASET_MENU = TopMenu("データセット(&D)", "_dataset_menu", action_attr="_dataset_menu_action",
157 on_show="_populate_dataset_menu", fill_now=True)
159VIEW_MENU = TopMenu("表示(&V)", "_view_menu", (
160 DockToggle("ui.control_dock_widget", "プロパティパネル"),
161 Item("エクスポートプレビュー", "show_export_preview"),
162 Custom(_add_bottom_panel_items),
163 Item("ミニマップ(レンジスライダー)", "_on_toggle_minimap", attr="minimap_action", checked="minimap_visible"),
164 Item("キャンバスを別ウィンドウに切り離す", "_on_toggle_canvas_detached", attr="canvas_detach_action",
165 checked="canvas_detached"),
166 Item("パネルラベルを自動表示 ((a)(b)(c)...)", "_on_toggle_panel_labels", attr="panel_labels_action",
167 checked="project.panel_labels_enabled"),
168 Item("色覚シミュレーションプレビュー...", "_on_show_cvd_simulation", attr="cvd_simulation_action"),
169 # クイックアクセスのツールバーはここで作る(表示メニューの項目と同じ順番で組み立てる必要がある)
170 Call("_create_quick_access_toolbar"),
171 SEPARATOR,
172 Item("ダークモード", "_on_toggle_dark_mode", attr="dark_mode_action", checked="canvas.dark_mode"),
173))
175HELP_MENU = TopMenu("ヘルプ(&H)", "_help_menu", (
176 Item("mathtext リファレンス...", "_on_show_help"),
177 Item("列計算機能 リファレンス...", "_on_show_calc_help"),
178 Item("キーボードショートカット一覧...", "_on_show_shortcuts"),
179 SEPARATOR,
180 Item("診断情報をエクスポート...", "_on_export_diagnostic_bundle"),
181 Item("アップデートを確認...", "_on_check_for_update"),
182 Item("プラグインの更新を確認...", "_on_check_plugin_updates"),
183 SEPARATOR,
184 Item("{app} について...", "_on_show_about"),
185))
188def _resolve(app, dotted):
189 obj = app
190 for part in dotted.split('.'):
191 obj = getattr(obj, part)
192 return obj
195def _set_attr(app, name, value):
196 if name:
197 setattr(app, name, value)
200def _add_items(app, menu, items, keep):
201 for item in items:
202 if item is SEPARATOR:
203 menu.addSeparator()
204 elif isinstance(item, Item):
205 action = menu.addAction(tr(item.text).format(app=APP_NAME) if '{app}' in item.text else tr(item.text))
206 if item.shortcut is not None:
207 action.setShortcut(item.shortcut)
208 slot = getattr(app, item.slot)
209 if item.checked is not None:
210 action.setCheckable(True)
211 action.setChecked(_resolve(app, item.checked))
212 action.toggled.connect(slot)
213 else:
214 action.triggered.connect(slot)
215 keep.append(action)
216 _set_attr(app, item.attr, action)
217 if item.after:
218 getattr(app, item.after)()
219 elif isinstance(item, Submenu):
220 submenu = menu.addMenu(tr(item.text))
221 _hold_menu(app, submenu, item.attr, item.action_attr, keep)
222 _add_items(app, submenu, item.items, keep)
223 _connect_on_show(app, submenu, item.on_show, item.fill_now)
224 if item.after:
225 getattr(app, item.after)()
226 elif isinstance(item, DockToggle):
227 action = _resolve(app, item.dock).toggleViewAction()
228 action.setText(tr(item.text))
229 menu.addAction(action)
230 keep.append(action)
231 elif isinstance(item, Call):
232 getattr(app, item.method)()
233 elif isinstance(item, Custom): 233 ↛ 236line 233 didn't jump to line 236 because the condition on line 233 was always true
234 item.build(app, menu, keep)
235 else:
236 raise TypeError(f"メニューの一覧表に知らない要素があります: {item!r}")
239def _hold_menu(app, menu, attr, action_attr, keep):
240 # QMenu だけでなく、開くための menuAction も持つ(こちらが回収されるとメニューごと消える)
241 keep.extend([menu, menu.menuAction()])
242 _set_attr(app, attr, menu)
243 _set_attr(app, action_attr, menu.menuAction())
246def _connect_on_show(app, menu, on_show, fill_now):
247 if on_show:
248 menu.aboutToShow.connect(getattr(app, on_show))
249 if fill_now: 249 ↛ exitline 249 didn't return from function '_connect_on_show' because the condition on line 249 was always true
250 getattr(app, on_show)()
253def _add_top_menu(app, menu_bar, top, keep):
254 menu = menu_bar.addMenu(tr(top.text))
255 _hold_menu(app, menu, top.attr, top.action_attr, keep)
256 _add_items(app, menu, top.items, keep)
257 _connect_on_show(app, menu, top.on_show, top.fill_now)
258 return menu
261def _add_plugin_menu(app, menu_bar, keep):
262 """プラグインがメニュー項目・データ処理・解析・パネルのどれかを登録しているときだけ作る。"""
263 api = app.plugin_api
264 processors = api.get_processors()
265 analyzers = api.get_analyzers()
266 if not (api.menu_actions or processors or analyzers or app._plugin_panels):
267 return
268 plugin_menu = menu_bar.addMenu(tr("プラグイン(&P)"))
269 _hold_menu(app, plugin_menu, "_plugin_menu", None, keep)
270 for menu_action in api.menu_actions:
271 action = plugin_menu.addAction(menu_action.text)
272 if menu_action.shortcut: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 action.setShortcut(QKeySequence(menu_action.shortcut))
274 action.triggered.connect(lambda checked=False, ma=menu_action: app._run_plugin_menu_action(ma))
275 keep.append(action)
277 if processors: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 if api.menu_actions:
279 plugin_menu.addSeparator()
280 processing_menu = plugin_menu.addMenu(tr("データ処理"))
281 _hold_menu(app, processing_menu, None, None, keep)
282 by_category = {}
283 for proc in processors:
284 by_category.setdefault(proc.category, []).append(proc)
285 for category in sorted(by_category.keys()):
286 category_menu = processing_menu.addMenu(category)
287 _hold_menu(app, category_menu, None, None, keep)
288 for proc in sorted(by_category[category], key=lambda p: p.name):
289 action = category_menu.addAction(proc.name)
290 action.triggered.connect(lambda checked=False, p=proc: app.plugin_runs.run_processor(p))
291 keep.append(action)
293 if analyzers: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 if api.menu_actions or processors:
295 plugin_menu.addSeparator()
296 analysis_menu = plugin_menu.addMenu(tr("解析"))
297 _hold_menu(app, analysis_menu, None, None, keep)
298 for analyzer in sorted(analyzers, key=lambda a: a.name):
299 action = analysis_menu.addAction(analyzer.name)
300 action.triggered.connect(lambda checked=False, a=analyzer: app.plugin_runs.run_analyzer(a))
301 keep.append(action)
303 if app._plugin_panels:
304 if api.menu_actions or processors or analyzers: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 plugin_menu.addSeparator()
306 panel_menu = plugin_menu.addMenu(tr("パネル"))
307 _hold_menu(app, panel_menu, None, None, keep)
308 # パネルは左端の帯の「プラグイン」のページにある。選ぶと、そのページでそのパネルを出す
309 for name in sorted(app._plugin_panels.keys()):
310 action = panel_menu.addAction(name)
311 action.triggered.connect(lambda checked=False, n=name: app.show_plugin_panel(n))
312 keep.append(action)
315def build_menu_bar(app):
316 """app のメニューバーを一覧表から組み立てる。__init__ から1回だけ呼ぶ。"""
317 keep = []
318 app._menu_keepalive = keep
319 menu_bar = app.menuBar()
320 for top in (FILE_MENU, EDIT_MENU, DATASET_MENU, VIEW_MENU):
321 _add_top_menu(app, menu_bar, top, keep)
322 # アプリ全体のスタイル(Fusion)とテーマを当て直す(何度呼んでも同じ)
323 apply_theme(QApplication.instance(), app.canvas.dark_mode)
324 _add_plugin_menu(app, menu_bar, keep)
325 _add_top_menu(app, menu_bar, HELP_MENU, keep)