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

1"""Typed undo/redo stack entries. 

2 

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. 

8 

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

12 

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 

18 

19from dataclasses import dataclass, field 

20from typing import Any, List, Sequence, Tuple 

21 

22from .i18n import tr 

23 

24 

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 

31 

32 

33@dataclass 

34class UndoEntry: 

35 """Base class for every undo/redo stack entry.""" 

36 

37 #: Label shown in the history panel. Subclasses override. 

38 HISTORY_LABEL = "編集" 

39 

40 @property 

41 def history_label(self) -> str: 

42 return self.HISTORY_LABEL 

43 

44 @property 

45 def nbytes(self) -> int: 

46 """Approximate retained image memory, for stack trimming.""" 

47 return 0 

48 

49 

50@dataclass 

51class ExposureUndo(UndoEntry): 

52 """Exposure and reversible cell moves, without retaining image snapshots.""" 

53 

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

62 

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) 

67 

68 

69@dataclass 

70class DocUndo(UndoEntry): 

71 """Whole-document snapshot: ``(frames, current_frame, active_layer, w, h)``.""" 

72 

73 HISTORY_LABEL = "全体編集" 

74 

75 snapshot: Any 

76 

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 ) 

88 

89 

90@dataclass 

91class InitialStateUndo(UndoEntry): 

92 """Initial images only, including cels kept outside the timesheet.""" 

93 

94 HISTORY_LABEL = "初期状態の読み込み" 

95 cells: List[Tuple[int, int, Any]] 

96 archived: List[Tuple[int, int, Any]] = field(default_factory=list) 

97 

98 @property 

99 def nbytes(self) -> int: 

100 return sum(_image_bytes(image) for _fi, _li, image in self.cells + self.archived) 

101 

102 

103@dataclass 

104class LayerUndo(UndoEntry): 

105 """Full snapshot of one layer image in one frame.""" 

106 

107 HISTORY_LABEL = "描画" 

108 

109 frame: int 

110 layer: int 

111 image: Any 

112 has_content: bool 

113 

114 @property 

115 def nbytes(self) -> int: 

116 return _image_bytes(self.image) 

117 

118 

119@dataclass 

120class LayerRegionUndo(UndoEntry): 

121 """Only the bounding box an operation could change.""" 

122 

123 HISTORY_LABEL = "描画" 

124 

125 frame: int 

126 layer: int 

127 rect: Any 

128 image: Any 

129 has_content: bool 

130 

131 @property 

132 def nbytes(self) -> int: 

133 return _image_bytes(self.image) 

134 

135 

136@dataclass 

137class LayerTilesUndo(UndoEntry): 

138 """Per-tile patches touched by a stroke: a sequence of ``(rect, image)``.""" 

139 

140 HISTORY_LABEL = "描画" 

141 

142 frame: int 

143 layer: int 

144 tiles: Sequence[Tuple[Any, Any]] 

145 has_content: bool 

146 

147 @property 

148 def nbytes(self) -> int: 

149 return sum(_image_bytes(image) for _rect, image in self.tiles) 

150 

151 

152@dataclass 

153class LayerBatchUndo(UndoEntry): 

154 """One layer across many frames: cells of ``(frame, image, has_content)``.""" 

155 

156 HISTORY_LABEL = "色編集" 

157 

158 layer: int 

159 cells: List[Tuple[int, Any, bool]] 

160 

161 @property 

162 def nbytes(self) -> int: 

163 return sum(_image_bytes(image) for _fi, image, _hc in self.cells) 

164 

165 

166@dataclass 

167class ScopedBatchUndo(UndoEntry): 

168 """一括処理ランナー1回分:``(frame, layer, image, has_content)`` のセル群。 

169 

170 ``LayerBatchUndo`` が1レイヤー固定だったのに対し、こちらはレイヤーを跨げる。 

171 どれだけのコマ・レイヤーへ適用しても Undo は常にこの1件になる。 

172 """ 

173 

174 HISTORY_LABEL = "一括処理" 

175 

176 cells: List[Tuple[int, int, Any, bool]] 

177 label: str = field(default="一括処理") 

178 

179 @property 

180 def history_label(self) -> str: 

181 return self.label or self.HISTORY_LABEL 

182 

183 @property 

184 def nbytes(self) -> int: 

185 return sum(_image_bytes(image) for _fi, _li, image, _hc in self.cells) 

186 

187 

188@dataclass 

189class LayerRemoveUndo(UndoEntry): 

190 """Undo of "add layer": drop the layer at ``index`` from every frame.""" 

191 

192 HISTORY_LABEL = "レイヤー構成" 

193 

194 index: int 

195 target_active: int 

196 

197 

198@dataclass 

199class LayerInsertUndo(UndoEntry): 

200 """Undo of "delete layer": re-insert the stored per-frame layers.""" 

201 

202 HISTORY_LABEL = "レイヤー構成" 

203 

204 index: int 

205 layers: List[Any] 

206 target_active: int 

207 

208 @property 

209 def nbytes(self) -> int: 

210 return sum(_image_bytes(layer.image) for layer in self.layers) 

211 

212 

213@dataclass 

214class TweenBatchUndo(UndoEntry): 

215 """A tween run: cells of ``(frame, image, has_content, exposure)``. 

216 

217 ``restore_count`` is the frame count to restore, since tweening can add or 

218 remove frames. 

219 """ 

220 

221 HISTORY_LABEL = "トゥイーン" 

222 

223 layer: int 

224 restore_count: int 

225 start: int 

226 end: int 

227 cells: List[Tuple[int, Any, bool, int]] 

228 

229 @property 

230 def nbytes(self) -> int: 

231 return sum(_image_bytes(image) for _fi, image, _hc, _exp in self.cells) 

232 

233 

234@dataclass 

235class PaletteStateUndo(UndoEntry): 

236 """Used-color panel state. Carries its own label per operation kind.""" 

237 

238 state: Any 

239 label: str = field(default="パレット編集") 

240 

241 @property 

242 def history_label(self) -> str: 

243 return self.label or "パレット編集" 

244 

245 

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) 

250 

251 

252def translate_history_label(label: str) -> str: 

253 """Translate a history label at display time. 

254 

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. 

259 

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) 

277 

278 

279@dataclass 

280class TransformUndo(UndoEntry): 

281 """Geometry history retained only while a selection transform is active.""" 

282 

283 HISTORY_LABEL = "変形操作" 

284 points: tuple 

285 cols: int 

286 rows: int 

287 reference_points: tuple 

288 

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