Coverage for paintmaskanimator/canvas_undo.py: 77%
275 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"""Undo/redo stacks for :class:`PaintCanvas`.
3Split out of ``canvas.py`` as a mixin. These methods push document / layer /
4region snapshots onto the undo stack and apply them on undo/redo. They run
5against a live ``PaintCanvas`` instance and its ``_document`` + stacks.
6"""
7from PySide6.QtCore import QRect, QPointF
8from PySide6.QtGui import QPainter
9from .constants import MAX_UNDO, MAX_UNDO_BYTES
10from ._canvas_members import CanvasMembers
11from . import constants
12from .models import Layer, default_layer_name
13from .utils import blank_image
14from .logging_setup import get_logger
15from .undo_entries import (
16 TransformUndo,
17 DocUndo,
18 ExposureUndo,
19 InitialStateUndo,
20 LayerBatchUndo,
21 LayerInsertUndo,
22 LayerRegionUndo,
23 LayerRemoveUndo,
24 LayerTilesUndo,
25 LayerUndo,
26 PaletteStateUndo,
27 ScopedBatchUndo,
28 TweenBatchUndo,
29 history_label_for,
30)
32log = get_logger(__name__)
34__all__ = [
35 "HistoryBranch",
36 "MAX_HISTORY_BRANCHES",
37 "UndoMixin",
38 "history_label_for",
39 "set_undo_limits",
40 "trim_undo_stack",
41]
43#: 保持する分岐の最大本数。古いものから捨てる。
44MAX_HISTORY_BRANCHES = 20
47class HistoryBranch:
48 """Undo後に新しい編集をして捨てられた「もう一つの未来」1本分。
50 ``position`` は分岐点(そのRedo列が適用できる状態)の undo_stack 長さ。
51 ``entries`` は捨てられた redo_stack のコピーで、末尾が分岐点の直後に
52 進む1手。ヒストリーパネルはこれをブランチとして表示し、選んだら
53 ``UndoMixin.switch_history_branch`` でその未来へ戻せる。
54 """
56 __slots__ = ("position", "entries", "label")
58 def __init__(self, position, entries, label):
59 self.position = int(position)
60 self.entries = list(entries)
61 self.label = str(label)
63 def __repr__(self): # pragma: no cover - デバッグ用
64 return (
65 f"HistoryBranch(position={self.position}, "
66 f"steps={len(self.entries)}, label={self.label!r})"
67 )
70def set_undo_limits(max_entries, max_bytes):
71 """Change the limits :func:`trim_undo_stack` enforces (from Preferences).
73 Existing stacks are trimmed on their next push, not immediately.
74 """
75 global MAX_UNDO, MAX_UNDO_BYTES
76 MAX_UNDO = max(1, int(max_entries))
77 MAX_UNDO_BYTES = max(1, int(max_bytes))
80def trim_undo_stack(stack):
81 """Drop the oldest entries until the stack fits both undo limits.
83 Bounded by entry count *and* retained image bytes, whichever binds first;
84 at least one entry always survives so a single huge edit stays undoable.
85 """
86 if len(stack) > MAX_UNDO:
87 del stack[:-MAX_UNDO]
88 total = sum(entry.nbytes for entry in stack)
89 while len(stack) > 1 and total > MAX_UNDO_BYTES:
90 total -= stack[0].nbytes
91 del stack[0]
92 return stack
95class UndoMixin(CanvasMembers):
96 def document_snapshot(self): return self._document.snapshot()
98 def push_undo(self, entry):
99 """Push one undo entry, trim to the limits, and branch off redo.
101 Every producer goes through here so the trim/branch pair cannot be
102 forgotten at a new call site. Undoしてから新しく編集した場合、捨てる
103 はずだったRedo列はブランチとして残し、あとから選び直せるようにする。
104 """
105 before = len(self.undo_stack)
106 self.undo_stack.append(entry)
107 trim_undo_stack(self.undo_stack)
108 removed = before + 1 - len(self.undo_stack)
109 if removed > 0: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 self._shift_history_branches(-removed)
111 if self.redo_stack:
112 # 分岐点は「今の編集を積む直前の状態」=新エントリ1件手前。
113 self._archive_redo_branch(len(self.undo_stack) - 1)
114 self.redo_stack.clear()
115 return entry
117 @property
118 def history_branches(self):
119 """捨てられた未来(ブランチ)の一覧。初回アクセスで作る。"""
120 branches = getattr(self, "_history_branches", None)
121 if branches is None:
122 branches = []
123 self._history_branches = branches
124 return branches
126 def clear_history_branches(self):
127 self.history_branches.clear()
129 def _shift_history_branches(self, delta):
130 """undo_stackの古い側が削られた分だけ分岐点をずらす(範囲外は破棄)。"""
131 branches = self.history_branches
132 shifted = []
133 for branch in branches:
134 branch.position += int(delta)
135 if branch.position >= 0:
136 shifted.append(branch)
137 branches[:] = shifted
139 def _archive_redo_branch(self, position):
140 """現在のredo_stackを1本のブランチとして退避する。"""
141 entries = list(self.redo_stack)
142 if not entries: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 return None
144 branch = HistoryBranch(
145 max(0, int(position)),
146 entries,
147 history_label_for(entries[-1]),
148 )
149 branches = self.history_branches
150 branches.append(branch)
151 if len(branches) > MAX_HISTORY_BRANCHES: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 del branches[:-MAX_HISTORY_BRANCHES]
153 return branch
155 def switch_history_branch(self, branch):
156 """分岐点まで巻き戻し、そのブランチの未来をRedo列として採用する。
158 ``branch`` は :class:`HistoryBranch` か ``history_branches`` の添字。
159 いま辿っていた未来は入れ替わりにブランチとして保存されるので、
160 行ったり来たりできる。
161 """
162 branches = self.history_branches
163 if isinstance(branch, int): 163 ↛ 167line 163 didn't jump to line 167 because the condition on line 163 was always true
164 if not (0 <= branch < len(branches)):
165 return False
166 branch = branches[branch]
167 if branch not in branches: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 return False
169 position = int(branch.position)
170 if position > len(self.undo_stack): 170 ↛ 172line 170 didn't jump to line 172 because the condition on line 170 was never true
171 # 履歴が縮んで到達できなくなったブランチは捨てる。
172 branches.remove(branch)
173 return False
174 for _ in range(len(self.undo_stack) - position):
175 if not self.undo_stack: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 break
177 self.undo()
178 branches.remove(branch)
179 # 巻き戻しで redo_stack に溜まった「いま辿っていた未来」を保存する。
180 self._archive_redo_branch(len(self.undo_stack))
181 self.redo_stack[:] = branch.entries
182 # 分岐を行き来すると多数の QImage が生成・破棄され、QImage.cacheKey() が
183 # 使い回されることがある。表示キャッシュはこのキーで引くため、別分岐の
184 # 表示(矩形状の描画パッチ)が残って見えることがある。分岐切り替え時は
185 # キャッシュを捨て、次の描画で実ピクセルから作り直させる。
186 self._invalidate_display_caches()
187 return True
189 def _invalidate_display_caches(self):
190 """QImage.cacheKey() で引く表示派生キャッシュを一括で捨てる。
192 分岐を行き来すると QImage が生成・破棄され cacheKey() が使い回されるため、
193 別状態の派生画像が残って見えることがある。分岐切り替え時に捨て、次の描画で
194 実ピクセルから作り直させる。
195 """
196 self._color_filter_cache.clear()
197 self._color_index_cache.clear()
198 self._silhouette_cache.clear()
199 self._onion_cache.clear()
200 self.update()
202 def push_doc_undo(self):
203 self.push_undo(DocUndo(self.document_snapshot()))
205 def push_layer_undo(self):
206 layer = self.active_layer
207 self.push_undo(LayerUndo(
208 int(self.current_frame),
209 int(self.active_layer_index),
210 layer.image.copy(),
211 bool(layer.has_content),
212 ))
214 def push_layer_region_undo(self, rect):
215 """Store only the part of the active layer an operation can change."""
216 layer = self.active_layer
217 rect = QRect(rect).intersected(layer.image.rect())
218 if rect.isEmpty(): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 return False
220 self.push_undo(LayerRegionUndo(
221 int(self.current_frame),
222 int(self.active_layer_index),
223 rect,
224 layer.image.copy(rect),
225 bool(layer.has_content),
226 ))
227 return True
229 def apply_undo_entry(self, e):
230 if isinstance(e, TransformUndo):
231 self.transform_points = [QPointF(x, y) for x, y in e.points]
232 self.transform_mesh_cols = self._active_mesh_cols = e.cols
233 self.transform_mesh_rows = self._active_mesh_rows = e.rows
234 self.transform_mesh_grid = e.cols
235 self.transform_mesh_reference_points = [QPointF(x, y) for x, y in e.reference_points]
236 self.transform_drag_kind = None
237 self._invalidate_tp_preview_cache()
238 self.selectionChanged.emit()
239 elif isinstance(e, InitialStateUndo):
240 for fi, li, image in e.cells:
241 if 0 <= fi < len(self.frames) and 0 <= li < len(self.frames[fi].layers): 241 ↛ 240line 241 didn't jump to line 240 because the condition on line 241 was always true
242 self.frames[fi].layers[li].initial_image = image
243 for li, number, image in e.archived:
244 archived = self._sequence_archive.get((li, number))
245 if archived is not None: 245 ↛ 243line 245 didn't jump to line 243 because the condition on line 245 was always true
246 archived.initial_image = image
247 self._playback_frame_cache.clear()
248 self.changed.emit()
249 elif isinstance(e, LayerTilesUndo):
250 if ( 250 ↛ 417line 250 didn't jump to line 417 because the condition on line 250 was always true
251 0 <= e.frame < len(self.frames)
252 and 0 <= e.layer < len(self.frames[e.frame].layers)
253 ):
254 fi, li = e.frame, e.layer
255 self.current_frame = fi
256 self.active_layer_index = li
257 layer = self.frames[fi].layers[li]
258 painter = QPainter(layer.image)
259 painter.setCompositionMode(
260 QPainter.CompositionMode.CompositionMode_Source
261 )
262 for rect, image in e.tiles:
263 painter.drawImage(QRect(rect).topLeft(), image)
264 painter.end()
265 layer.has_content = bool(e.has_content)
266 self.cellChanged.emit(fi, li)
267 self.selectionChanged.emit()
268 elif isinstance(e, LayerRegionUndo):
269 if ( 269 ↛ 417line 269 didn't jump to line 417 because the condition on line 269 was always true
270 0 <= e.frame < len(self.frames)
271 and 0 <= e.layer < len(self.frames[e.frame].layers)
272 ):
273 fi, li = e.frame, e.layer
274 self.current_frame = fi
275 self.active_layer_index = li
276 layer = self.frames[fi].layers[li]
277 painter = QPainter(layer.image)
278 painter.setCompositionMode(
279 QPainter.CompositionMode.CompositionMode_Source
280 )
281 painter.drawImage(QRect(e.rect).topLeft(), e.image)
282 painter.end()
283 layer.has_content = bool(e.has_content)
284 self.cellChanged.emit(fi, li)
285 self.selectionChanged.emit()
286 elif isinstance(e, PaletteStateUndo):
287 # 使用色パネルの並び順・親子プレビュー・表示/マスクを復元する。
288 palette = getattr(self, "_palette", None)
289 if palette is not None: 289 ↛ 417line 289 didn't jump to line 417 because the condition on line 289 was always true
290 palette.restore_history_state(e.state)
291 elif isinstance(e, ExposureUndo):
292 self._ensure_frame_count(e.frame_count)
293 moved = [(dst, self.frames[src].layers[e.layer].clone())
294 for src, dst in e.moves]
295 for src, _dst in e.moves:
296 self._clear_timeline_layer_cell(self.frames[src].layers[e.layer])
297 for dst, layer in moved:
298 self.frames[dst].layers[e.layer] = layer
299 del self.frames[e.frame_count:]
300 self.frames[e.column].layers[e.layer].exposure = e.exposure
301 self.current_frame = e.current_frame
302 self.active_layer_index = e.active_layer
303 self._invalidate_display_caches()
304 self.changed.emit()
305 self.selectionChanged.emit()
306 elif isinstance(e, DocUndo):
307 fs, cf, al, w, h = e.snapshot
308 constants.CANVAS_WIDTH = w
309 constants.CANVAS_HEIGHT = h
310 # The entry was popped before application and its inverse has
311 # already been captured. Transfer the stored snapshot directly;
312 # cloning every frame/layer/image again only adds latency.
313 self.frames = fs
314 self.current_frame = cf
315 self.active_layer_index = al
316 self.coalesce_numbered_images()
317 self.link_layer_tracks()
318 self.changed.emit()
319 elif isinstance(e, LayerBatchUndo): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 li = e.layer
321 self.active_layer_index = li
322 for fi, img, hc in e.cells:
323 if 0 <= fi < len(self.frames) and 0 <= li < len(self.frames[fi].layers):
324 self.frames[fi].layers[li].image = img
325 self.frames[fi].layers[li].has_content = hc
326 self.cellChanged.emit(fi, li)
327 self.selectionChanged.emit()
328 elif isinstance(e, ScopedBatchUndo):
329 # 一括処理ランナー(frame_scope)1回分。レイヤーを跨いで復元する。
330 for fi, li, img, hc in e.cells:
331 if ( 331 ↛ 330line 331 didn't jump to line 330 because the condition on line 331 was always true
332 0 <= fi < len(self.frames)
333 and 0 <= li < len(self.frames[fi].layers)
334 ):
335 self.frames[fi].layers[li].image = img
336 self.frames[fi].layers[li].has_content = hc
337 self.cellChanged.emit(fi, li)
338 if e.cells: 338 ↛ 343line 338 didn't jump to line 343 because the condition on line 338 was always true
339 self.active_layer_index = max(
340 0,
341 min(int(e.cells[0][1]), len(self.layers) - 1),
342 )
343 self._onion_cache.clear()
344 self.selectionChanged.emit()
345 elif isinstance(e, LayerRemoveUndo):
346 index = e.index
347 for frame in self.frames:
348 if 0 <= index < len(frame.layers) and len(frame.layers) > 1: 348 ↛ 347line 348 didn't jump to line 347 because the condition on line 348 was always true
349 frame.layers.pop(index)
350 self.active_layer_index = max(
351 0,
352 min(int(e.target_active), len(self.layers) - 1),
353 )
354 self._onion_cache.clear()
355 self.changed.emit()
356 self.selectionChanged.emit()
357 elif isinstance(e, LayerInsertUndo):
358 index = e.index
359 stored_layers = e.layers
360 for frame_index, frame in enumerate(self.frames):
361 if frame_index < len(stored_layers): 361 ↛ 364line 361 didn't jump to line 364 because the condition on line 361 was always true
362 layer = stored_layers[frame_index]
363 else:
364 layer = Layer(
365 default_layer_name(index),
366 blank_image(),
367 visible=True,
368 opacity=1.0,
369 )
370 frame.layers.insert(
371 max(0, min(int(index), len(frame.layers))),
372 layer,
373 )
374 self.active_layer_index = max(
375 0,
376 min(int(e.target_active), len(self.layers) - 1),
377 )
378 self._onion_cache.clear()
379 self.changed.emit()
380 self.selectionChanged.emit()
381 elif isinstance(e, TweenBatchUndo): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 li = e.layer
383 restore_count = max(1, int(e.restore_count))
384 if len(self.frames) < restore_count:
385 self._ensure_frame_count(restore_count)
386 for fi, image, has_content, exposure in e.cells:
387 if (
388 0 <= fi < len(self.frames)
389 and 0 <= li < len(self.frames[fi].layers)
390 ):
391 layer = self.frames[fi].layers[li]
392 layer.image = image
393 layer.has_content = bool(has_content)
394 layer.exposure = max(1, int(exposure))
395 if len(self.frames) > restore_count:
396 del self.frames[restore_count:]
397 self.current_frame = max(
398 0, min(int(e.start), len(self.frames) - 1)
399 )
400 self.active_layer_index = max(
401 0, min(int(li), len(self.layers) - 1)
402 )
403 self._onion_cache.clear()
404 self.changed.emit()
405 self.selectionChanged.emit()
406 elif isinstance(e, LayerUndo): 406 ↛ 415line 406 didn't jump to line 415 because the condition on line 406 was always true
407 fi, li = e.frame, e.layer
408 self.current_frame = fi
409 self.active_layer_index = li
410 self.frames[fi].layers[li].image = e.image
411 self.frames[fi].layers[li].has_content = e.has_content
412 self.cellChanged.emit(fi, li)
413 self.selectionChanged.emit()
414 else:
415 log.warning("unknown undo entry type: %r", type(e).__name__)
416 return
417 self.update()
419 def undo(self):
420 if self.transform_active and (not self.undo_stack or not isinstance(self.undo_stack[-1], TransformUndo)): 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 self.cancel_selection_transform()
422 return
423 while self.undo_stack: 423 ↛ exitline 423 didn't return from function 'undo' because the condition on line 423 was always true
424 entry = self.undo_stack.pop()
425 current = self.current_state_for(entry)
426 if current is None: 426 ↛ 429line 426 didn't jump to line 429 because the condition on line 426 was never true
427 # タイムラインの削除・正規化後に残った、既に存在しない
428 # セルの古い履歴は安全に破棄する。
429 continue
430 self.redo_stack.append(current)
431 self.apply_undo_entry(entry)
432 return
434 def redo(self):
435 if self.transform_active and (not self.redo_stack or not isinstance(self.redo_stack[-1], TransformUndo)): 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 return
437 while self.redo_stack: 437 ↛ exitline 437 didn't return from function 'redo' because the condition on line 437 was always true
438 entry = self.redo_stack.pop()
439 current = self.current_state_for(entry)
440 if current is None: 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true
441 continue
442 self.undo_stack.append(current)
443 self.apply_undo_entry(entry)
444 return