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

1""" 

2メニューバーの中身の一覧表と、それを組み立てる関数。 

3 

4項目は PlotterApp のメソッド名で呼び先を指す。コマンドパレット・クイックアクセス・ショートカット一覧は 

5メニューを辿って項目を集め、そのパス(「ファイル > エクスポート > 印刷(&R)...」)を識別子として保存するので、 

6文字列を変えると保存済みのピン留めが外れる。 

7 

8PySide6 は、Python 側の参照が無くなった QMenu・menuAction・QAction を後から回収することがある 

9(メニューを辿ったとたん "already deleted" になる)。組み立てたものはすべて app._menu_keepalive に持たせる。 

10""" 

11from dataclasses import dataclass 

12from typing import Callable, Optional 

13 

14from PySide6.QtGui import QAction, QKeySequence 

15from PySide6.QtWidgets import QApplication 

16 

17from graphica.core.i18n import tr 

18from graphica.core.version import APP_NAME 

19from graphica.gui.theme import apply_theme 

20 

21 

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 

35 

36 

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 

47 

48 

49@dataclass(frozen=True) 

50class DockToggle: 

51 """ドックの表示切り替え(ドック自身の toggleViewAction)。dock は app からの点区切りの名前。""" 

52 dock: str 

53 text: str 

54 

55 

56@dataclass(frozen=True) 

57class Call: 

58 """メニューとは別に、この位置で呼ぶ app のメソッド(組み立ての順番に意味があるもの)。""" 

59 method: str 

60 

61 

62@dataclass(frozen=True) 

63class Custom: 

64 """表で書けない項目。build(app, menu, keep) が menu に足す。""" 

65 build: Callable 

66 

67 

68SEPARATOR = object() 

69 

70 

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 

79 

80 

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

89 

90 

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) 

98 

99 

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 

109 

110 

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

147 

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

154 

155# 右クリックと同じ中身を開くたびに作る(片方だけに項目を足さないため) 

156DATASET_MENU = TopMenu("データセット(&D)", "_dataset_menu", action_attr="_dataset_menu_action", 

157 on_show="_populate_dataset_menu", fill_now=True) 

158 

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

174 

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

186 

187 

188def _resolve(app, dotted): 

189 obj = app 

190 for part in dotted.split('.'): 

191 obj = getattr(obj, part) 

192 return obj 

193 

194 

195def _set_attr(app, name, value): 

196 if name: 

197 setattr(app, name, value) 

198 

199 

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

237 

238 

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

244 

245 

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

251 

252 

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 

259 

260 

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) 

276 

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) 

292 

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) 

302 

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) 

313 

314 

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)