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

1"""Saved workspaces (dock layouts); owned by ``MainWindow`` as ``window.workspace``. 

2 

3Persists and restores named dock/window layouts through :mod:`.config`, using the 

4window's ``dock_manager`` and menu bar. 

5 

6A collaborator rather than a mixin -- see ``main_window_export.py`` for why. 

7""" 

8from typing import TYPE_CHECKING 

9 

10from .i18n import tr 

11from PySide6.QtCore import QByteArray, QRect, QTimer 

12from PySide6.QtWidgets import QApplication, QInputDialog 

13 

14from . import config 

15 

16 

17if TYPE_CHECKING: 

18 from .main_window import MainWindow 

19 

20 

21class WorkspaceController: 

22 """Owned by ``MainWindow`` as ``window.workspace``. 

23 

24 A collaborator rather than a mixin -- see main_window_export.py for why. 

25 """ 

26 

27 def __init__(self, window: "MainWindow"): 

28 self.window = window 

29 # 起動時に組み立てた既定の配置。保存済みワークスペースとは別に、 

30 # メニューの「初期設定」からいつでも戻れるようにする。 

31 self._default_state: QByteArray | None = None 

32 

33 """Persist, apply, and manage named dock-layout workspaces.""" 

34 

35 def records(self): 

36 records = config.get_value("workspaces", {}) 

37 return records if isinstance(records, dict) else {} 

38 

39 def names(self): 

40 """Workspace names in the user's order (the saved dict order).""" 

41 return list(self.records()) 

42 

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

48 

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

56 

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 

71 

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 } 

81 

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 

92 

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 

113 

114 @staticmethod 

115 def clamped_geometry(geometry): 

116 """geometry を、いま一番近い画面の作業領域に収めた矩形にする。 

117 

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) 

125 

126 def overlap(screen): 

127 area = screen.availableGeometry().intersected(geometry) 

128 return area.width() * area.height() 

129 

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) 

139 

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) 

150 

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

155 

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 

172 

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 

183 

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) 

190 

191 def build_menu(self): 

192 self.window.workspace_menu = self.window.menuBar().addMenu(tr("ワークスペース")) 

193 self.refresh_menu() 

194 

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 )