Coverage for paintmaskanimator/undo_entries.py: 98%
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"""Typed undo/redo stack entries.
3Undo entries used to be tag-dispatched tuples (``("layer", fi, li, img, hc)``
4and seven other shapes), which meant every producer and consumer agreed on
5positional indices by convention only -- unreadable, uncheckable, and hostile
6to refactoring. Each shape is a dataclass here instead, so a wrong field name
7is a type error rather than a silent ``IndexError`` at undo time.
9Each entry also reports ``nbytes``, letting the stack be bounded by memory
10rather than by entry count alone (a 4K full-layer snapshot and a 32x32 tile
11patch are not remotely the same cost).
13The classes are deliberately dumb records: the logic that captures the inverse
14state and re-applies an entry lives on ``PaintCanvas`` (``current_state_for`` /
15``apply_undo_entry``), because it needs the live document.
16"""
17from __future__ import annotations
19from dataclasses import dataclass, field
20from typing import Any, List, Sequence, Tuple
22from .i18n import tr
25def _image_bytes(image) -> int:
26 """Best-effort in-memory size of a QImage (0 when it is not one)."""
27 try:
28 return int(image.sizeInBytes())
29 except (AttributeError, TypeError):
30 return 0
33@dataclass
34class UndoEntry:
35 """Base class for every undo/redo stack entry."""
37 #: Label shown in the history panel. Subclasses override.
38 HISTORY_LABEL = "編集"
40 @property
41 def history_label(self) -> str:
42 return self.HISTORY_LABEL
44 @property
45 def nbytes(self) -> int:
46 """Approximate retained image memory, for stack trimming."""
47 return 0
50@dataclass
51class ExposureUndo(UndoEntry):
52 """Exposure and reversible cell moves, without retaining image snapshots."""
54 HISTORY_LABEL = "コマの長さ変更"
55 layer: int
56 column: int
57 exposure: int
58 frame_count: int
59 current_frame: int
60 active_layer: int
61 moves: tuple = ()
63 @classmethod
64 def capture(cls, canvas, layer, column, moves=()):
65 return cls(layer, column, canvas.frames[column].layers[layer].exposure,
66 len(canvas.frames), canvas.current_frame, canvas.active_layer_index, moves)
69@dataclass
70class DocUndo(UndoEntry):
71 """Whole-document snapshot: ``(frames, current_frame, active_layer, w, h)``."""
73 HISTORY_LABEL = "全体編集"
75 snapshot: Any
77 @property
78 def nbytes(self) -> int:
79 try:
80 frames = self.snapshot[0]
81 except (TypeError, IndexError):
82 return 0
83 return sum(
84 _image_bytes(layer.image)
85 for frame in frames
86 for layer in frame.layers
87 )
90@dataclass
91class InitialStateUndo(UndoEntry):
92 """Initial images only, including cels kept outside the timesheet."""
94 HISTORY_LABEL = "初期状態の読み込み"
95 cells: List[Tuple[int, int, Any]]
96 archived: List[Tuple[int, int, Any]] = field(default_factory=list)
98 @property
99 def nbytes(self) -> int:
100 return sum(_image_bytes(image) for _fi, _li, image in self.cells + self.archived)
103@dataclass
104class LayerUndo(UndoEntry):
105 """Full snapshot of one layer image in one frame."""
107 HISTORY_LABEL = "描画"
109 frame: int
110 layer: int
111 image: Any
112 has_content: bool
114 @property
115 def nbytes(self) -> int:
116 return _image_bytes(self.image)
119@dataclass
120class LayerRegionUndo(UndoEntry):
121 """Only the bounding box an operation could change."""
123 HISTORY_LABEL = "描画"
125 frame: int
126 layer: int
127 rect: Any
128 image: Any
129 has_content: bool
131 @property
132 def nbytes(self) -> int:
133 return _image_bytes(self.image)
136@dataclass
137class LayerTilesUndo(UndoEntry):
138 """Per-tile patches touched by a stroke: a sequence of ``(rect, image)``."""
140 HISTORY_LABEL = "描画"
142 frame: int
143 layer: int
144 tiles: Sequence[Tuple[Any, Any]]
145 has_content: bool
147 @property
148 def nbytes(self) -> int:
149 return sum(_image_bytes(image) for _rect, image in self.tiles)
152@dataclass
153class LayerBatchUndo(UndoEntry):
154 """One layer across many frames: cells of ``(frame, image, has_content)``."""
156 HISTORY_LABEL = "色編集"
158 layer: int
159 cells: List[Tuple[int, Any, bool]]
161 @property
162 def nbytes(self) -> int:
163 return sum(_image_bytes(image) for _fi, image, _hc in self.cells)
166@dataclass
167class ScopedBatchUndo(UndoEntry):
168 """一括処理ランナー1回分:``(frame, layer, image, has_content)`` のセル群。
170 ``LayerBatchUndo`` が1レイヤー固定だったのに対し、こちらはレイヤーを跨げる。
171 どれだけのコマ・レイヤーへ適用しても Undo は常にこの1件になる。
172 """
174 HISTORY_LABEL = "一括処理"
176 cells: List[Tuple[int, int, Any, bool]]
177 label: str = field(default="一括処理")
179 @property
180 def history_label(self) -> str:
181 return self.label or self.HISTORY_LABEL
183 @property
184 def nbytes(self) -> int:
185 return sum(_image_bytes(image) for _fi, _li, image, _hc in self.cells)
188@dataclass
189class LayerRemoveUndo(UndoEntry):
190 """Undo of "add layer": drop the layer at ``index`` from every frame."""
192 HISTORY_LABEL = "レイヤー構成"
194 index: int
195 target_active: int
198@dataclass
199class LayerInsertUndo(UndoEntry):
200 """Undo of "delete layer": re-insert the stored per-frame layers."""
202 HISTORY_LABEL = "レイヤー構成"
204 index: int
205 layers: List[Any]
206 target_active: int
208 @property
209 def nbytes(self) -> int:
210 return sum(_image_bytes(layer.image) for layer in self.layers)
213@dataclass
214class TweenBatchUndo(UndoEntry):
215 """A tween run: cells of ``(frame, image, has_content, exposure)``.
217 ``restore_count`` is the frame count to restore, since tweening can add or
218 remove frames.
219 """
221 HISTORY_LABEL = "トゥイーン"
223 layer: int
224 restore_count: int
225 start: int
226 end: int
227 cells: List[Tuple[int, Any, bool, int]]
229 @property
230 def nbytes(self) -> int:
231 return sum(_image_bytes(image) for _fi, image, _hc, _exp in self.cells)
234@dataclass
235class PaletteStateUndo(UndoEntry):
236 """Used-color panel state. Carries its own label per operation kind."""
238 state: Any
239 label: str = field(default="パレット編集")
241 @property
242 def history_label(self) -> str:
243 return self.label or "パレット編集"
246def history_label_for(entry) -> str:
247 """Undoエントリ1件を、ヒストリー表示用の短いラベルに変換する。"""
248 label = entry.history_label if isinstance(entry, UndoEntry) else tr("編集")
249 return translate_history_label(label)
252def translate_history_label(label: str) -> str:
253 """Translate a history label at display time.
255 ``HISTORY_LABEL`` lives in a class body, which is evaluated at import time --
256 before the translator is installed -- so it holds the untranslated source and
257 is translated here instead. ``pyside6-lupdate`` only sees a literal inside
258 ``tr()``, hence the table.
260 Entries that carry a runtime label (a palette edit, a scoped batch) pass it
261 in already translated; an unknown key is returned unchanged, which is exactly
262 what those need.
263 """
264 labels = {
265 "編集": tr("編集"),
266 "全体編集": tr("全体編集"),
267 "コマの長さ変更": tr("コマの長さ変更"),
268 "描画": tr("描画"),
269 "色編集": tr("色編集"),
270 "一括処理": tr("一括処理"),
271 "レイヤー構成": tr("レイヤー構成"),
272 "トゥイーン": tr("トゥイーン"),
273 "パレット編集": tr("パレット編集"),
274 "初期状態の読み込み": tr("初期状態の読み込み"),
275 }
276 return labels.get(label, label)
279@dataclass
280class TransformUndo(UndoEntry):
281 """Geometry history retained only while a selection transform is active."""
283 HISTORY_LABEL = "変形操作"
284 points: tuple
285 cols: int
286 rows: int
287 reference_points: tuple
289 @classmethod
290 def capture(cls, canvas):
291 return cls(tuple((p.x(), p.y()) for p in canvas.transform_points),
292 canvas.transform_mesh_cols, canvas.transform_mesh_rows,
293 tuple((p.x(), p.y()) for p in canvas.transform_mesh_reference_points))