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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""最上位のウィンドウ。各タブは独立した PlotterApp(QMainWindow)で、Undo もメニューもドックも別々に持つ。
3アプリ全体で1回だけの処理(オートセーブの復元確認、初回の案内、ドック配置の保存、clean_exit)は最初のタブだけが行う。
4上部バー(gui/shell/header.py)はここに1つだけ置き、押したときは表示中のタブに届ける。
5"""
6import logging
7import sys
9from PySide6.QtCore import Qt
10from PySide6.QtGui import QIcon, QUndoGroup
11from PySide6.QtWidgets import QApplication, QDockWidget, QMainWindow, QMenu, QUndoView, QVBoxLayout, QWidget
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
22logger = logging.getLogger(__name__)
24DEFAULT_WINDOW_WIDTH = 1300
25DEFAULT_WINDOW_HEIGHT = 850
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))
35 # ウィンドウ全体の大きさと位置はここで持つ
36 self._settings = app_settings.open_settings()
37 # 最初のタブがスタートアップ画面を見たことにする前に決める(今までの利用者はアドバンスドで始める)
38 self.ui_mode = ui_mode.initial_mode(self._settings)
40 # 各タブは自分の QUndoStack を持ち、ここでグループにまとめて、表示中のタブのものを有効にする
41 # (履歴パネルと上部バーの元に戻す・やり直すは、グループを通して表示中のタブに効く)
42 self.undo_group = QUndoGroup(self)
43 self._create_undo_history_dock()
44 # 履歴パネルはこのウィンドウのドックなので、タブとは別にここでもフォーカスの強調を付ける
45 theme.install_dock_focus_highlight(self)
47 self._next_tab_id = 1
48 self._tab_menus = {} # タブ -> ≡ で開くメニュー(回収されないよう持つ)
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)
56 self.header = AppHeader(self.tab_widget.tabBar(), icon_path)
57 self._connect_header()
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)
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)
76 # 最初のタブは起動時の確認をするタブ
77 self.add_new_project_tab(run_startup_checks=True)
79 @property
80 def add_tab_button(self):
81 return self.header.add_tab_button
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履歴"))
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)))
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()
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()
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)
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
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()])
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
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())
179 def add_new_project_tab(self, run_startup_checks=False):
180 tab_id = self._next_tab_id
181 self._next_tab_id += 1
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 )
191 self.undo_group.addStack(project_window.undo_stack)
192 self._attach_tab_chrome(project_window)
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
202 def open_files(self, paths, reuse_empty_tab=False):
203 """外から渡されたファイル(起動の引数・起動中のアプリへの受け渡し・macOS の Finder)を開く。
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)]
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()
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()
233 def _tab_title_for(self, project_window):
234 return project_window.document_title()
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)
243 def _update_window_title(self, project_window):
244 self.setWindowTitle(f"{APP_NAME} {__version__} - {self._tab_title_for(project_window)}")
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)
260 def _on_tab_close_requested(self, index):
261 """最後の1つを閉じたら、空のプロジェクトのタブに置き換えてスタートアップ画面を出す。
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)
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)