Coverage for paintmaskanimator/main_window_workspace.py: 82%
148 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Saved workspaces (dock layouts); owned by ``MainWindow`` as ``window.workspace``.
3Persists and restores named dock/window layouts through :mod:`.config`, using the
4window's ``dock_manager`` and menu bar.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING
10from .i18n import tr
11from PySide6.QtCore import QByteArray, QRect, QTimer
12from PySide6.QtWidgets import QApplication, QInputDialog
14from . import config
17if TYPE_CHECKING:
18 from .main_window import MainWindow
21class WorkspaceController:
22 """Owned by ``MainWindow`` as ``window.workspace``.
24 A collaborator rather than a mixin -- see main_window_export.py for why.
25 """
27 def __init__(self, window: "MainWindow"):
28 self.window = window
29 # 起動時に組み立てた既定の配置。保存済みワークスペースとは別に、
30 # メニューの「初期設定」からいつでも戻れるようにする。
31 self._default_state: QByteArray | None = None
33 """Persist, apply, and manage named dock-layout workspaces."""
35 def records(self):
36 records = config.get_value("workspaces", {})
37 return records if isinstance(records, dict) else {}
39 def names(self):
40 """Workspace names in the user's order (the saved dict order)."""
41 return list(self.records())
43 def _changed(self):
44 self.refresh_menu()
45 dialog = getattr(self.window, "preferences_dialog", None)
46 if dialog is not None:
47 dialog.reload_workspaces()
49 def reorder(self, names):
50 """Store the workspaces in ``names`` order; unknown names are ignored."""
51 records = self.records()
52 ordered = [n for n in names if n in records]
53 ordered += [n for n in records if n not in ordered]
54 config.set_value("workspaces", {n: records[n] for n in ordered} or None)
55 self._changed()
57 def rename(self, old, new):
58 """Rename in place (keeping its position); False if empty or taken."""
59 new = str(new).strip()
60 records = self.records()
61 if old not in records or not new or (new != old and new in records): 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 return False
63 config.set_value(
64 "workspaces",
65 {(new if n == old else n): r for n, r in records.items()},
66 )
67 if config.get_value("active_workspace") == old: 67 ↛ 69line 67 didn't jump to line 69 because the condition on line 67 was always true
68 config.set_value("active_workspace", new)
69 self._changed()
70 return True
72 def capture(self):
73 return {
74 "dock_state": bytes(
75 self.window.dock_manager.saveState().toBase64()
76 ).decode("ascii"),
77 "window_geometry": bytes(
78 self.window.saveGeometry().toBase64().data()
79 ).decode("ascii"),
80 }
82 def save(self, name):
83 name = str(name).strip()
84 if not name: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 return False
86 records = self.records()
87 records[name] = self.capture()
88 config.set_value("workspaces", records)
89 config.set_value("active_workspace", name)
90 self._changed()
91 return True
93 def apply(self, name, restore_geometry=True):
94 record = self.records().get(name)
95 if not isinstance(record, dict): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 return False
97 state = QByteArray.fromBase64(
98 str(record.get("dock_state", "")).encode("ascii")
99 )
100 if state.isEmpty() or not self.window.dock_manager.restoreState(state): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 return False
102 self.clamp_floating_windows()
103 if restore_geometry: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 geometry = QByteArray.fromBase64(
105 str(record.get("window_geometry", "")).encode("ascii")
106 )
107 if not geometry.isEmpty():
108 self.window.restoreGeometry(geometry)
109 config.set_value("active_workspace", name)
110 QTimer.singleShot(0, self.window._sync_all_area_hamburgers)
111 self._changed()
112 return True
114 @staticmethod
115 def clamped_geometry(geometry):
116 """geometry を、いま一番近い画面の作業領域に収めた矩形にする。
118 保存時と画面構成が変わっていると、復元した座標がどの画面にも
119 載らないことがある。そのままフロートウィンドウへ渡すと Windows が
120 勝手に別の位置へ置き直し、要求と結果が食い違ったという警告を出す。
121 """
122 screens = QApplication.screens()
123 if not screens: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 return QRect(geometry)
126 def overlap(screen):
127 area = screen.availableGeometry().intersected(geometry)
128 return area.width() * area.height()
130 best = max(screens, key=overlap)
131 available = best.availableGeometry()
132 if overlap(best) > 0 and available.contains(geometry):
133 return QRect(geometry)
134 width = min(geometry.width(), available.width())
135 height = min(geometry.height(), available.height())
136 x = min(max(geometry.x(), available.left()), available.right() - width + 1)
137 y = min(max(geometry.y(), available.top()), available.bottom() - height + 1)
138 return QRect(x, y, width, height)
140 def clamp_floating_windows(self):
141 """復元したフロートパネルを、いまの画面構成の中へ押し戻す。"""
142 for floating in self.window.dock_manager.floatingWidgets():
143 try:
144 geometry = QRect(floating.geometry())
145 except RuntimeError:
146 continue
147 clamped = self.clamped_geometry(geometry)
148 if clamped != geometry: 148 ↛ 142line 148 didn't jump to line 142 because the condition on line 148 was always true
149 floating.setGeometry(clamped)
151 def remember_default(self, state):
152 if state is not None and not state.isEmpty(): 152 ↛ exitline 152 didn't return from function 'remember_default' because the condition on line 152 was always true
153 self._default_state = QByteArray(state)
154 self.refresh_menu()
156 def apply_default(self):
157 """起動直後と同じ既定のパネル配置に戻す。"""
158 if self._default_state is None: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 return False
160 if not self.window.dock_manager.restoreState(self._default_state): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 return False
162 config.set_value("active_workspace", None)
163 # 覚えておいた状態はパネルが最小幅に潰れているので、初回起動と同じく
164 # 実際のウィンドウサイズから幅と高さを配り直す。
165 layout = self.window.layout()
166 if layout is not None: 166 ↛ 168line 166 didn't jump to line 168 because the condition on line 166 was always true
167 layout.activate()
168 self.window._apply_default_dock_layout()
169 QTimer.singleShot(0, self.window._sync_all_area_hamburgers)
170 self._changed()
171 return True
173 def delete(self, name):
174 records = self.records()
175 if name not in records: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 return False
177 del records[name]
178 config.set_value("workspaces", records or None)
179 if config.get_value("active_workspace") == name:
180 config.set_value("active_workspace", None)
181 self._changed()
182 return True
184 def prompt_save(self):
185 name, accepted = QInputDialog.getText(
186 self.window, tr("ワークスペースを保存"), tr("ワークスペース名:")
187 )
188 if accepted and name.strip():
189 self.save(name)
191 def build_menu(self):
192 self.window.workspace_menu = self.window.menuBar().addMenu(tr("ワークスペース"))
193 self.refresh_menu()
195 def refresh_menu(self):
196 menu = getattr(self.window, "workspace_menu", None)
197 if menu is None: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 return
199 menu.clear()
200 save_action = menu.addAction(tr("現在の配置を保存…"))
201 save_action.triggered.connect(self.prompt_save)
202 records = self.records()
203 active = config.get_value("active_workspace")
204 menu.addSeparator()
205 default_action = menu.addAction(tr("初期設定"))
206 default_action.setCheckable(True)
207 default_action.setChecked(not active or active not in records)
208 default_action.setEnabled(self._default_state is not None)
209 default_action.triggered.connect(
210 lambda _checked=False: self.apply_default()
211 )
212 if records:
213 for name in records:
214 action = menu.addAction(name)
215 action.setCheckable(True)
216 action.setChecked(name == active)
217 action.triggered.connect(
218 lambda _checked=False, n=name: self.apply(n)
219 )
220 menu.addSeparator()
221 manage_action = menu.addAction(tr("ワークスペースを管理…"))
222 manage_action.triggered.connect(
223 lambda _checked=False: self.window.show_preferences("workspaces")
224 )