Coverage for graphica/gui/main_app_window.py: 90.5%

217 statements  

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

1"""最上位のウィンドウ。各タブは独立した PlotterApp(QMainWindow)で、Undo もメニューもドックも別々に持つ。 

2 

3アプリ全体で1回だけの処理(オートセーブの復元確認、初回の案内、ドック配置の保存、clean_exit)は最初のタブだけが行う。 

4上部バー(gui/shell/header.py)はここに1つだけ置き、押したときは表示中のタブに届ける。 

5""" 

6import logging 

7import sys 

8 

9from PySide6.QtCore import Qt 

10from PySide6.QtGui import QIcon, QUndoGroup 

11from PySide6.QtWidgets import QApplication, QDockWidget, QMainWindow, QMenu, QUndoView, QVBoxLayout, QWidget 

12 

13from graphica.core.i18n import tr 

14from graphica.gui import app_settings, theme, ui_mode 

15from graphica.gui.theme_color import theme_color_or_default 

16from graphica.gui.main_window import PlotterApp, resource_path 

17from graphica.gui.shell.header import AppHeader 

18from graphica.gui.shell.project_tabs import ProjectTabs 

19from graphica.core.version import APP_NAME, __version__ 

20from graphica.models.project import LEGACY_PICKLE_EXTENSION, PROJECT_FILE_EXTENSIONS 

21 

22logger = logging.getLogger(__name__) 

23 

24DEFAULT_WINDOW_WIDTH = 1300 

25DEFAULT_WINDOW_HEIGHT = 850 

26 

27 

28class MainAppWindow(QMainWindow): 

29 def __init__(self): 

30 super().__init__() 

31 self.setWindowTitle(f"{APP_NAME} {__version__}") 

32 icon_path = resource_path("Graphica.ico") 

33 self.setWindowIcon(QIcon(icon_path)) 

34 

35 # ウィンドウ全体の大きさと位置はここで持つ 

36 self._settings = app_settings.open_settings() 

37 # 最初のタブがスタートアップ画面を見たことにする前に決める(今までの利用者はアドバンスドで始める) 

38 self.ui_mode = ui_mode.initial_mode(self._settings) 

39 

40 # 各タブは自分の QUndoStack を持ち、ここでグループにまとめて、表示中のタブのものを有効にする 

41 # (履歴パネルと上部バーの元に戻す・やり直すは、グループを通して表示中のタブに効く) 

42 self.undo_group = QUndoGroup(self) 

43 self._create_undo_history_dock() 

44 # 履歴パネルはこのウィンドウのドックなので、タブとは別にここでもフォーカスの強調を付ける 

45 theme.install_dock_focus_highlight(self) 

46 

47 self._next_tab_id = 1 

48 self._tab_menus = {} # タブ -> ≡ で開くメニュー(回収されないよう持つ) 

49 

50 self.tab_widget = ProjectTabs() 

51 self.tab_widget.setTabsClosable(True) 

52 self.tab_widget.setMovable(True) 

53 self.tab_widget.tabCloseRequested.connect(self._on_tab_close_requested) 

54 self.tab_widget.currentChanged.connect(self._on_current_tab_changed) 

55 

56 self.header = AppHeader(self.tab_widget.tabBar(), icon_path) 

57 self._connect_header() 

58 

59 central = QWidget() 

60 central_layout = QVBoxLayout(central) 

61 central_layout.setContentsMargins(0, 0, 0, 0) 

62 central_layout.setSpacing(0) 

63 central_layout.addWidget(self.header) 

64 central_layout.addWidget(self.tab_widget, 1) 

65 self.setCentralWidget(central) 

66 

67 saved_geometry = app_settings.WINDOW_GEOMETRY.read(self._settings) 

68 if saved_geometry is not None: 68 ↛ 71line 68 didn't jump to line 71 because the condition on line 68 was never true

69 # 先にネイティブのハンドルを作る。窓が実体化する前に restoreGeometry() すると枠の幅が確定しておらず、 

70 # 画面上の位置の認識がずれて、ポップアップやクリックの位置が全部ずれる 

71 self.winId() 

72 self.restoreGeometry(saved_geometry) 

73 else: 

74 self.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) 

75 

76 # 最初のタブは起動時の確認をするタブ 

77 self.add_new_project_tab(run_startup_checks=True) 

78 

79 @property 

80 def add_tab_button(self): 

81 return self.header.add_tab_button 

82 

83 def _create_undo_history_dock(self): 

84 """表示中のタブの Undo 履歴(既定では隠す)。""" 

85 self.undo_history_dock = QDockWidget(tr("Undo履歴"), self) 

86 self.undo_history_dock.setObjectName("undo_history_dock") 

87 undo_view = QUndoView(self.undo_group, self.undo_history_dock) 

88 self.undo_history_dock.setWidget(undo_view) 

89 self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.undo_history_dock) 

90 self.undo_history_dock.setVisible(False) 

91 self.undo_history_action = self.undo_history_dock.toggleViewAction() 

92 self.undo_history_action.setText(tr("Undo履歴")) 

93 

94 def _connect_header(self): 

95 header = self.header 

96 header.add_tab_button.clicked.connect(lambda: self.add_new_project_tab()) 

97 self._undo_action = self.undo_group.createUndoAction(self, tr("元に戻す")) 

98 self._redo_action = self.undo_group.createRedoAction(self, tr("やり直し")) 

99 for button, action in ((header.undo_button, self._undo_action), (header.redo_button, self._redo_action)): 

100 icon = button.icon() 

101 button.setDefaultAction(action) 

102 # setDefaultAction() はボタンのアイコンを QAction のもので上書きするので、戻す 

103 action.setIcon(icon) 

104 header.save_button.clicked.connect(lambda: self._trigger_on_current_tab("save_project_action")) 

105 header.export_button.clicked.connect(lambda: self._trigger_on_current_tab("save_action")) 

106 header.search_button.clicked.connect(lambda: self._trigger_on_current_tab("command_palette_action")) 

107 header.set_mode(self.ui_mode) 

108 header.mode_switch.currentIndexChanged.connect( 

109 lambda index: self.set_ui_mode(header.mode_switch.itemData(index))) 

110 

111 def _trigger_on_current_tab(self, action_attr): 

112 tab = self.tab_widget.currentWidget() 

113 action = getattr(tab, action_attr, None) if tab is not None else None 

114 if action is not None: 114 ↛ exitline 114 didn't return from function '_trigger_on_current_tab' because the condition on line 114 was always true

115 action.trigger() 

116 

117 def set_theme_color(self, color): 

118 """テーマカラーを替えて保存し、全部のタブと上部バーに当てる(QSS とパレットはアプリ全体、アイコンや図はタブごと)。""" 

119 color = theme_color_or_default(color) 

120 app_settings.THEME_COLOR.write(self._settings, color) 

121 current = self.tab_widget.currentWidget() 

122 dark = current.canvas.dark_mode if current is not None else app_settings.DARK_MODE.read(self._settings) 

123 theme.apply_theme(QApplication.instance(), dark, color) 

124 for index in range(self.tab_widget.count()): 

125 tab = self.tab_widget.widget(index) 

126 if tab is not None and hasattr(tab, 'refresh_theme_colors'): 126 ↛ 124line 126 didn't jump to line 124 because the condition on line 126 was always true

127 tab.refresh_theme_colors() 

128 self.header.refresh_icons() 

129 

130 def set_ui_mode(self, mode): 

131 """シンプル / アドバンスドを全部のタブに当て、次の起動のために保存する。""" 

132 if mode not in ui_mode.MODES: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 return 

134 self.ui_mode = mode 

135 app_settings.UI_MODE.write(self._settings, mode) 

136 self.header.set_mode(mode) 

137 for index in range(self.tab_widget.count()): 

138 tab = self.tab_widget.widget(index) 

139 if tab is not None: 139 ↛ 137line 139 didn't jump to line 137 because the condition on line 139 was always true

140 ui_mode.apply_ui_mode(tab, mode) 

141 

142 def _attach_tab_chrome(self, project_window): 

143 """タブを上部バーにつなぐ: ≡ のメニュー、ショートカット、クイックアクセス、モード、アイコンの色。""" 

144 menu = QMenu(self.header) 

145 # 最上位のメニューは、組み立て時に持たせた属性から取る(menuBar().actions() から .menu() で辿ると回収されることがある) 

146 for attr in ("_file_menu", "_edit_menu", "_dataset_menu", "_view_menu", "_plugin_menu", "_help_menu"): 

147 top_menu = getattr(project_window, attr, None) 

148 if top_menu is not None: 148 ↛ 146line 148 didn't jump to line 146 because the condition on line 148 was always true

149 menu.addMenu(top_menu) 

150 menu.addSeparator() 

151 menu.addAction(project_window.quick_access_manage_action) 

152 menu.addAction(self.undo_history_action) 

153 self._tab_menus[project_window] = menu 

154 

155 if sys.platform != "darwin": 155 ↛ 158line 155 didn't jump to line 158 because the condition on line 155 was never true

156 # Windows では上部バーの ≡ にまとめる(macOS は画面上のメニューバーのまま)。メニューバーを隠すと 

157 # そこにしか無い QAction のショートカットが効かなくなるので、全部をタブにも足す 

158 project_window.menuBar().hide() 

159 project_window.addActions([action for _path, action in project_window._collect_menu_actions() 

160 if not action.shortcut().isEmpty()]) 

161 

162 toolbar = project_window.quick_access_toolbar 

163 project_window.removeToolBar(toolbar) 

164 toolbar.removeAction(project_window.quick_access_manage_action) 

165 # 管理の項目の後ろの区切り(管理は ≡ に移したので、先頭に残ると空の区切りだけが見える) 

166 actions = toolbar.actions() 

167 if actions and actions[0].isSeparator(): 167 ↛ 169line 167 didn't jump to line 169 because the condition on line 167 was always true

168 toolbar.removeAction(actions[0]) 

169 toolbar.setParent(None) 

170 toolbar.setIconSize(self.header.save_button.iconSize()) 

171 toolbar.show() 

172 self.header.quick_access_stack.addWidget(toolbar) 

173 self._tab_toolbars = getattr(self, "_tab_toolbars", {}) 

174 self._tab_toolbars[project_window] = toolbar 

175 

176 ui_mode.apply_ui_mode(project_window, self.ui_mode) 

177 project_window.dark_mode_action.toggled.connect(lambda _checked: self.header.refresh_icons()) 

178 

179 def add_new_project_tab(self, run_startup_checks=False): 

180 tab_id = self._next_tab_id 

181 self._next_tab_id += 1 

182 

183 project_window = PlotterApp( 

184 run_startup_checks=run_startup_checks, 

185 tab_id=(None if run_startup_checks else tab_id), 

186 ) 

187 project_window.project_state_changed.connect( 

188 lambda pw=project_window: self._refresh_tab_title(pw) 

189 ) 

190 

191 self.undo_group.addStack(project_window.undo_stack) 

192 self._attach_tab_chrome(project_window) 

193 

194 # 埋め込んだ QMainWindow は最上位ウィンドウのフラグが残るので外す。親を確定させてから1回だけ変える 

195 # (変更と付け替えの2段階にすると、macOS でタブが入力を受け付けなくなる) 

196 index = self.tab_widget.addTab(project_window, self._tab_title_for(project_window)) 

197 project_window.setWindowFlags(Qt.WindowType.Widget) 

198 self.tab_widget.setCurrentIndex(index) 

199 project_window.show() 

200 return project_window 

201 

202 def open_files(self, paths, reuse_empty_tab=False): 

203 """外から渡されたファイル(起動の引数・起動中のアプリへの受け渡し・macOS の Finder)を開く。 

204 

205 プロジェクトは 1 つずつ新しいタブに開き、データファイルは最後に開いたタブ(無ければ新しいタブ)に加える。 

206 reuse_empty_tab なら、今のタブが空で未保存の変更も無いときだけ最初のプロジェクトをそこに開く(起動直後)。 

207 """ 

208 project_suffixes = PROJECT_FILE_EXTENSIONS + (LEGACY_PICKLE_EXTENSION,) 

209 project_paths = [p for p in paths if p.lower().endswith(project_suffixes)] 

210 data_paths = [p for p in paths if not p.lower().endswith(project_suffixes)] 

211 

212 target = None 

213 current = self.tab_widget.currentWidget() 

214 if reuse_empty_tab and current is not None and not current.project.datasets \ 

215 and not current.has_unsaved_changes(): 

216 target = current 

217 for index, path in enumerate(project_paths): 

218 if target is None or index > 0: 

219 target = self.add_new_project_tab() 

220 target._load_project_from_path(path) 

221 if data_paths: 

222 if target is None: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 target = current if reuse_empty_tab and current is not None else self.add_new_project_tab() 

224 target._queue_data_files(data_paths) 

225 self.bring_to_front() 

226 

227 def bring_to_front(self): 

228 if self.isMinimized(): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 self.showNormal() 

230 self.raise_() 

231 self.activateWindow() 

232 

233 def _tab_title_for(self, project_window): 

234 return project_window.document_title() 

235 

236 def _refresh_tab_title(self, project_window): 

237 index = self.tab_widget.indexOf(project_window) 

238 if index != -1: 238 ↛ exitline 238 didn't return from function '_refresh_tab_title' because the condition on line 238 was always true

239 self.tab_widget.setTabText(index, self._tab_title_for(project_window)) 

240 if index == self.tab_widget.currentIndex(): 240 ↛ exitline 240 didn't return from function '_refresh_tab_title' because the condition on line 240 was always true

241 self._update_window_title(project_window) 

242 

243 def _update_window_title(self, project_window): 

244 self.setWindowTitle(f"{APP_NAME} {__version__} - {self._tab_title_for(project_window)}") 

245 

246 def _on_current_tab_changed(self, index): 

247 if index == -1: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 return 

249 project_window = self.tab_widget.widget(index) 

250 if project_window is not None: 250 ↛ exitline 250 didn't return from function '_on_current_tab_changed' because the condition on line 250 was always true

251 self._update_window_title(project_window) 

252 self.undo_group.setActiveStack(project_window.undo_stack) 

253 menu = self._tab_menus.get(project_window) 

254 if menu is not None: 254 ↛ 256line 254 didn't jump to line 256 because the condition on line 254 was always true

255 self.header.menu_button.setMenu(menu) 

256 toolbar = getattr(self, "_tab_toolbars", {}).get(project_window) 

257 if toolbar is not None: 257 ↛ exitline 257 didn't return from function '_on_current_tab_changed' because the condition on line 257 was always true

258 self.header.quick_access_stack.setCurrentWidget(toolbar) 

259 

260 def _on_tab_close_requested(self, index): 

261 """最後の1つを閉じたら、空のプロジェクトのタブに置き換えてスタートアップ画面を出す。 

262 

263 タブが無くなる時はつくらない(メニューは各タブが持つので、0枚だと何もできなくなる)。 

264 """ 

265 project_window = self.tab_widget.widget(index) 

266 if project_window is not None and not project_window.confirm_unsaved_changes("タブを閉じる"): 

267 return 

268 replacement = self.add_new_project_tab() if self.tab_widget.count() <= 1 else None 

269 self.tab_widget.removeTab(self.tab_widget.indexOf(project_window)) 

270 if project_window is not None: 270 ↛ 280line 270 didn't jump to line 280 because the condition on line 270 was always true

271 # 閉じた直後から履歴パネルの対象に残らないよう、明示的に外す 

272 self.undo_group.removeStack(project_window.undo_stack) 

273 self._tab_menus.pop(project_window, None) 

274 toolbar = getattr(self, "_tab_toolbars", {}).pop(project_window, None) 

275 if toolbar is not None: 275 ↛ 278line 275 didn't jump to line 278 because the condition on line 275 was always true

276 self.header.quick_access_stack.removeWidget(toolbar) 

277 toolbar.deleteLater() 

278 project_window.close() 

279 project_window.deleteLater() 

280 if replacement is not None: 

281 replacement._show_welcome_dialog(last_tab_closed=True) 

282 

283 def closeEvent(self, event): 

284 """全部のタブに正常終了の処理をさせてから閉じる。""" 

285 # 未保存の変更は、そのタブを前に出して尋ねる。全部尋ねてから閉じ始めるので、1つでもキャンセルなら何も閉じない 

286 for index in range(self.tab_widget.count()): 

287 project_window = self.tab_widget.widget(index) 

288 if project_window is None or not project_window.has_unsaved_changes(): 

289 continue 

290 self.tab_widget.setCurrentIndex(index) 

291 if not project_window.confirm_unsaved_changes("Graphica を終了する"): 

292 event.ignore() 

293 return 

294 app_settings.WINDOW_GEOMETRY.write(self._settings, self.saveGeometry()) 

295 for index in range(self.tab_widget.count()): 

296 project_window = self.tab_widget.widget(index) 

297 if project_window is not None: 297 ↛ 295line 297 didn't jump to line 295 because the condition on line 297 was always true

298 project_window.close() 

299 super().closeEvent(event)