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
« 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``.
3Adds, duplicates, merges, deletes, reorders and edits layer rows across all
4frames, keeping the timeline widget in sync.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING
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
19if TYPE_CHECKING:
20 from .main_window import MainWindow
22log = get_logger(__name__)
25class LayerOpsController:
26 """Owned by ``MainWindow`` as ``window.layers``.
28 A collaborator rather than a mixin -- see main_window_export.py for why.
29 """
31 def __init__(self, window: "MainWindow"):
32 self.window = window
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)
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([])
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()
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
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()
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 ]
106 previous_signature = None
107 active_key_frame = None
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 = []
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
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 ))
145 signature = tuple(signature_parts)
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()
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
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)
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])
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 )
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("削除後の使用色を更新しています"))
236 def move_row(self, source_rows, destination_row):
237 """レイヤー名と全コマのタイムラインデータを同じ順序で移動する。"""
238 if not self.window.canvas.frames:
239 return
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)]
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
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
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)
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))
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()
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()
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()
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)
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 )
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 )
370 @staticmethod
371 def _copy_display_properties(source, target):
372 """``target`` が ``source`` と同じレイヤー設定を見るようにする。
374 表示の設定は ``Track`` がレイヤーごとに1つ持つようになったので
375 (Issue #31 Phase 2)、8個の値を写す代わりに同じ ``Track`` を
376 指すようにするだけでよい。
377 """
378 target.share_track_with(source)