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

1"""Undo/redo stacks for :class:`PaintCanvas`. 

2 

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) 

31 

32log = get_logger(__name__) 

33 

34__all__ = [ 

35 "HistoryBranch", 

36 "MAX_HISTORY_BRANCHES", 

37 "UndoMixin", 

38 "history_label_for", 

39 "set_undo_limits", 

40 "trim_undo_stack", 

41] 

42 

43#: 保持する分岐の最大本数。古いものから捨てる。 

44MAX_HISTORY_BRANCHES = 20 

45 

46 

47class HistoryBranch: 

48 """Undo後に新しい編集をして捨てられた「もう一つの未来」1本分。 

49 

50 ``position`` は分岐点(そのRedo列が適用できる状態)の undo_stack 長さ。 

51 ``entries`` は捨てられた redo_stack のコピーで、末尾が分岐点の直後に 

52 進む1手。ヒストリーパネルはこれをブランチとして表示し、選んだら 

53 ``UndoMixin.switch_history_branch`` でその未来へ戻せる。 

54 """ 

55 

56 __slots__ = ("position", "entries", "label") 

57 

58 def __init__(self, position, entries, label): 

59 self.position = int(position) 

60 self.entries = list(entries) 

61 self.label = str(label) 

62 

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 ) 

68 

69 

70def set_undo_limits(max_entries, max_bytes): 

71 """Change the limits :func:`trim_undo_stack` enforces (from Preferences). 

72 

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

78 

79 

80def trim_undo_stack(stack): 

81 """Drop the oldest entries until the stack fits both undo limits. 

82 

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 

93 

94 

95class UndoMixin(CanvasMembers): 

96 def document_snapshot(self): return self._document.snapshot() 

97 

98 def push_undo(self, entry): 

99 """Push one undo entry, trim to the limits, and branch off redo. 

100 

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 

116 

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 

125 

126 def clear_history_branches(self): 

127 self.history_branches.clear() 

128 

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 

138 

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 

154 

155 def switch_history_branch(self, branch): 

156 """分岐点まで巻き戻し、そのブランチの未来をRedo列として採用する。 

157 

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 

188 

189 def _invalidate_display_caches(self): 

190 """QImage.cacheKey() で引く表示派生キャッシュを一括で捨てる。 

191 

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

201 

202 def push_doc_undo(self): 

203 self.push_undo(DocUndo(self.document_snapshot())) 

204 

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

213 

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 

228 

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

418 

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 

433 

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