Coverage for paintmaskanimator/main_window_layer_ops.py: 22%

223 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Layer-row operations; owned by ``MainWindow`` as ``window.layers``. 

2 

3Adds, duplicates, merges, deletes, reorders and edits layer rows across all 

4frames, keeping the timeline widget in sync. 

5 

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

7""" 

8from typing import TYPE_CHECKING 

9 

10from PySide6.QtCore import Qt 

11from PySide6.QtGui import QPainter 

12from PySide6.QtWidgets import QApplication, QMessageBox 

13from .i18n import tr 

14from .models import Layer, default_layer_name 

15from .utils import blank_image 

16from .progress import close_counter, create_counter, update_counter 

17from .logging_setup import get_logger 

18 

19if TYPE_CHECKING: 

20 from .main_window import MainWindow 

21 

22log = get_logger(__name__) 

23 

24 

25class LayerOpsController: 

26 """Owned by ``MainWindow`` as ``window.layers``. 

27 

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

29 """ 

30 

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

32 self.window = window 

33 

34 def _indices_from_rows(self, rows): 

35 count = len(self.window.canvas.layers) 

36 result = [] 

37 for row in rows: 

38 index = count - 1 - int(row) 

39 if 0 <= index < count and index not in result: 39 ↛ 37line 39 didn't jump to line 37 because the condition on line 39 was always true

40 result.append(index) 

41 return sorted(result) 

42 

43 def add_fast(self): 

44 """新規空レイヤーでは全コマの使用色走査を行わず即時表示する。""" 

45 self.window._used_color_timer.stop() 

46 self.window._used_color_request += 1 

47 self.window._suppress_used_color_refresh_once = True 

48 self.window.canvas.add_layer() 

49 self.window.palette.set_colors([]) 

50 

51 def duplicate_rows(self, rows): 

52 indices = self._indices_from_rows(rows) 

53 if not indices: 

54 return 

55 self.window.canvas.push_doc_undo() 

56 for frame in self.window.canvas.frames: 

57 for index in sorted(indices, reverse=True): 

58 copied = frame.layers[index].clone() 

59 copied.name = tr("{name} コピー").format(name=copied.name) 

60 frame.layers.insert(index + 1, copied) 

61 self.window.canvas.active_layer_index = min( 

62 len(self.window.canvas.layers) - 1, 

63 max(indices) + len(indices), 

64 ) 

65 self.window.canvas._onion_cache.clear() 

66 self.window.canvas.changed.emit() 

67 self.window.canvas.selectionChanged.emit() 

68 self.window.canvas.update() 

69 

70 def merge_rows(self, rows): 

71 indices = self._indices_from_rows(rows) 

72 if len(indices) < 2: 

73 self.window.statusBar().showMessage( 

74 tr("結合するレイヤーをShift+クリックで2つ以上選択してください。"), 

75 2600, 

76 ) 

77 return 

78 if indices != list(range(indices[0], indices[-1] + 1)): 

79 self.window.statusBar().showMessage( 

80 tr("結合できるのは連続しているレイヤーです。"), 

81 2600, 

82 ) 

83 return 

84 

85 base_index = indices[0] 

86 top_index = indices[-1] 

87 result_name = self.window.canvas.layers[top_index].name 

88 frame_count = len(self.window.canvas.frames) 

89 self.window.canvas.push_doc_undo() 

90 

91 # 元レイヤーを削除する前に、各タイムライン位置で実際に表示される 

92 # キーフレームを解決する。これにより「ーーー|」の保持区間が 

93 # 白紙へ置き換わる問題を防ぐ。 

94 merged_layers = [ 

95 Layer( 

96 result_name, 

97 blank_image(), 

98 visible=True, 

99 opacity=1.0, 

100 has_content=False, 

101 exposure=1, 

102 ) 

103 for _ in range(frame_count) 

104 ] 

105 

106 previous_signature = None 

107 active_key_frame = None 

108 

109 progress = create_counter( 

110 self.window, 

111 tr("レイヤーを結合"), 

112 max(1, frame_count), 

113 tr("保持コマを解析しています"), 

114 ) 

115 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) 

116 try: 

117 for frame_index in range(frame_count): 

118 resolved = [] 

119 signature_parts = [] 

120 

121 for layer_index in indices: 

122 key_frame = self.window.canvas.resolve_key_frame( 

123 frame_index, layer_index 

124 ) 

125 if key_frame is None: 

126 continue 

127 source_layer = self.window.canvas.frames[ 

128 key_frame 

129 ].layers[layer_index] 

130 if ( 

131 not source_layer.has_content 

132 or not source_layer.visible 

133 or source_layer.opacity <= 0.0 

134 ): 

135 continue 

136 

137 resolved.append(source_layer) 

138 signature_parts.append(( 

139 int(layer_index), 

140 int(key_frame), 

141 int(source_layer.image.cacheKey()), 

142 round(float(source_layer.opacity), 6), 

143 )) 

144 

145 signature = tuple(signature_parts) 

146 

147 if not resolved: 

148 # 白紙区間では直前の露出を延長しない。 

149 previous_signature = None 

150 active_key_frame = None 

151 elif ( 

152 signature == previous_signature 

153 and active_key_frame is not None 

154 ): 

155 merged_layers[active_key_frame].exposure += 1 

156 else: 

157 merged_image = blank_image() 

158 painter = QPainter(merged_image) 

159 painter.setCompositionMode( 

160 QPainter.CompositionMode.CompositionMode_SourceOver 

161 ) 

162 for source_layer in resolved: 

163 painter.setOpacity( 

164 max( 

165 0.0, 

166 min(1.0, float(source_layer.opacity)), 

167 ) 

168 ) 

169 # 表示フィルターはデータへ焼き込まず、元画像を結合する。 

170 painter.drawImage(0, 0, source_layer.image) 

171 painter.end() 

172 

173 merged_layers[frame_index] = Layer( 

174 result_name, 

175 merged_image, 

176 visible=True, 

177 opacity=1.0, 

178 has_content=True, 

179 exposure=1, 

180 ) 

181 previous_signature = signature 

182 active_key_frame = frame_index 

183 

184 update_counter( 

185 progress, 

186 frame_index + 1, 

187 max(1, frame_count), 

188 tr("{value} / {count} コマを結合しています").format(value=frame_index + 1, count=frame_count), 

189 ) 

190 finally: 

191 QApplication.restoreOverrideCursor() 

192 close_counter(progress) 

193 

194 for frame_index, frame in enumerate(self.window.canvas.frames): 

195 for layer_index in reversed(indices): 

196 frame.layers.pop(layer_index) 

197 frame.layers.insert(base_index, merged_layers[frame_index]) 

198 

199 self.window.canvas.active_layer_index = base_index 

200 self.window.canvas._onion_cache.clear() 

201 self.window.canvas._color_filter_cache.clear() 

202 self.window.canvas._color_index_cache.clear() 

203 self.window.canvas._silhouette_cache.clear() 

204 self.window.canvas.changed.emit() 

205 self.window.canvas.selectionChanged.emit() 

206 self.window.canvas.update() 

207 self.window.used_color.refresh_with_counter( 

208 tr("結合後の使用色を更新しています") 

209 ) 

210 

211 def delete_rows(self, rows): 

212 indices = self._indices_from_rows(rows) 

213 if not indices: 

214 return 

215 if len(indices) >= len(self.window.canvas.layers): 

216 QMessageBox.warning( 

217 self.window, 

218 tr("レイヤー削除"), 

219 tr("すべてのレイヤーは削除できません。1つ以上残してください。"), 

220 ) 

221 return 

222 self.window.canvas.push_doc_undo() 

223 for frame in self.window.canvas.frames: 

224 for index in sorted(indices, reverse=True): 

225 frame.layers.pop(index) 

226 self.window.canvas.active_layer_index = min( 

227 indices[0], 

228 len(self.window.canvas.layers) - 1, 

229 ) 

230 self.window.canvas._onion_cache.clear() 

231 self.window.canvas.changed.emit() 

232 self.window.canvas.selectionChanged.emit() 

233 self.window.canvas.update() 

234 self.window.used_color.refresh_with_counter(tr("削除後の使用色を更新しています")) 

235 

236 def move_row(self, source_rows, destination_row): 

237 """レイヤー名と全コマのタイムラインデータを同じ順序で移動する。""" 

238 if not self.window.canvas.frames: 

239 return 

240 

241 count = len(self.window.canvas.layers) 

242 try: 

243 rows = sorted({int(row) for row in source_rows}) 

244 except TypeError: 

245 rows = [int(source_rows)] 

246 

247 if ( 

248 not rows 

249 or any(row < 0 or row >= count for row in rows) 

250 or rows != list(range(rows[0], rows[-1] + 1)) 

251 ): 

252 self.window.refresh_ui() 

253 return 

254 

255 block_count = len(rows) 

256 destination_row = max( 

257 0, 

258 min(int(destination_row), count - block_count), 

259 ) 

260 if destination_row == rows[0]: 

261 return 

262 

263 # 現在レイヤーを視覚行番号で記憶し、移動後も同じレイヤーを選択する。 

264 active_visual_row = count - 1 - self.window.canvas.active_layer_index 

265 visual_order = list(range(count)) 

266 moved_order = visual_order[rows[0]:rows[-1] + 1] 

267 del visual_order[rows[0]:rows[-1] + 1] 

268 visual_order[destination_row:destination_row] = moved_order 

269 new_active_visual_row = visual_order.index(active_visual_row) 

270 

271 self.window.canvas.push_doc_undo() 

272 for frame in self.window.canvas.frames: 

273 visual_layers = list(reversed(frame.layers)) 

274 moved_layers = visual_layers[rows[0]:rows[-1] + 1] 

275 del visual_layers[rows[0]:rows[-1] + 1] 

276 visual_layers[destination_row:destination_row] = moved_layers 

277 frame.layers = list(reversed(visual_layers)) 

278 

279 self.window.canvas.active_layer_index = count - 1 - new_active_visual_row 

280 self.window.canvas._onion_cache.clear() 

281 self.window.canvas.changed.emit() 

282 self.window.canvas.selectionChanged.emit() 

283 self.window.canvas.update() 

284 

285 def rename_row(self, row, name): 

286 if getattr(self.window, "_closing", False) or row < 0: 

287 return 

288 layers = self.window.canvas.layers 

289 index = len(layers) - 1 - row 

290 if not (0 <= index < len(layers)): 

291 return 

292 name = name.strip() or default_layer_name(index) 

293 self.window.canvas.push_doc_undo() 

294 for frame in self.window.canvas.frames: 

295 if index < len(frame.layers): 

296 frame.layers[index].name = name 

297 self.window.canvas.changed.emit() 

298 

299 def on_selected(self, row): 

300 if getattr(self.window, "_closing", False) or row < 0: 

301 return 

302 layers = self.window.canvas.layers 

303 index = len(layers) - 1 - row 

304 if 0 <= index < len(layers): 

305 if self.window.canvas.active_layer_index == index: 

306 return 

307 self.window.canvas.active_layer_index = index 

308 self.window.canvas._onion_cache.clear() 

309 self.window.timeline.select_current(self.window.canvas.current_exposure(), index) 

310 self.window.used_color._refresh_without_delay() 

311 self.window.canvas.update() 

312 

313 def set_visibility_row(self, row, on): 

314 if getattr(self.window, "_closing", False) or row < 0: 

315 return 

316 layers = self.window.canvas.layers 

317 index = len(layers) - 1 - row 

318 if 0 <= index < len(layers): 

319 self.window.canvas.set_layer_visibility(index, on) 

320 

321 def set_opacity_row(self, row, opacity): 

322 if getattr(self.window, "_closing", False) or row < 0: 

323 return 

324 layers = self.window.canvas.layers 

325 index = len(layers) - 1 - row 

326 if 0 <= index < len(layers): 

327 self.window.canvas.set_layer_opacity(index, opacity) 

328 # 一覧の保持値もその場で更新し、別レイヤー選択時に正しく復元する。 

329 item = self.window.timeline.layer_list.item(row) 

330 if item is not None: 

331 item.setData( 

332 Qt.ItemDataRole.UserRole + 5, 

333 float(opacity), 

334 ) 

335 

336 def set_draft_rows(self, rows): 

337 """選択レイヤーの下書きレイヤーモードを切り替える(後から変更可能)。""" 

338 indices = self._indices_from_rows(rows) 

339 if not indices: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 return 

341 layers = self.window.canvas.layers 

342 # 選択内の1つでも下書きでなければ全て下書きにする(トグル)。 

343 new_state = not all( 

344 bool(getattr(layers[index], "is_draft", False)) 

345 for index in indices 

346 ) 

347 self.window.canvas.push_doc_undo() 

348 for frame in self.window.canvas.frames: 

349 for index in indices: 

350 if index < len(frame.layers): 350 ↛ 349line 350 didn't jump to line 349 because the condition on line 350 was always true

351 frame.layers[index].is_draft = new_state 

352 self.window.canvas._onion_cache.clear() 

353 self.window.canvas._color_filter_cache.clear() 

354 self.window.canvas._color_index_cache.clear() 

355 self.window.canvas._silhouette_cache.clear() 

356 self.window._used_color_cache.clear() 

357 self.window._used_color_layer_cache.clear() 

358 self.window.canvas.changed.emit() 

359 self.window.canvas.selectionChanged.emit() 

360 self.window.canvas.update() 

361 self.window.used_color._refresh_without_delay() 

362 self.window.statusBar().showMessage( 

363 # One whole sentence per state: a translator cannot reorder around 

364 # a value spliced into the middle of a concatenation. 

365 tr("下書きレイヤーモードを有効化しました。") if new_state 

366 else tr("下書きレイヤーモードを解除しました。"), 

367 2600, 

368 ) 

369 

370 @staticmethod 

371 def _copy_display_properties(source, target): 

372 """``target`` が ``source`` と同じレイヤー設定を見るようにする。 

373 

374 表示の設定は ``Track`` がレイヤーごとに1つ持つようになったので 

375 (Issue #31 Phase 2)、8個の値を写す代わりに同じ ``Track`` を 

376 指すようにするだけでよい。 

377 """ 

378 target.share_track_with(source)