Coverage for paintmaskanimator/main_window_autosave.py: 82%
80 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"""Autosave / crash recovery; owned by ``MainWindow`` as ``window.autosave``.
3Owns the autosave timer and the crash-recovery flow, writing through the
4window's ``canvas``, ``timeline`` and ``build_project_metadata``.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING
9from concurrent.futures import ThreadPoolExecutor
10import copy
12from PySide6.QtCore import QTimer
13from PySide6.QtWidgets import QMessageBox
15from . import config, preferences, project_io
16from .i18n import tr
17from .logging_setup import get_logger
19if TYPE_CHECKING:
20 from .main_window import MainWindow
22log = get_logger(__name__)
25class AutosaveController:
26 """Owned by ``MainWindow`` as ``window.autosave``.
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
33 self._executor = None
34 self._pending = None
35 self._png_cache = project_io.PngCache()
37 """Periodic best-effort project snapshots + startup crash recovery."""
39 def path(self):
40 return config.config_dir() / "autosave.pmap"
42 def start(self):
43 """Periodically snapshot the project so a crash doesn't lose work."""
44 self.window._autosave_timer = QTimer(self.window)
45 self.window._autosave_timer.timeout.connect(self.request_save)
46 self.apply_preferences()
48 def apply_preferences(self):
49 """(Re)start or stop the timer from the autosave preferences."""
50 timer = self.window._autosave_timer
51 timer.setInterval(preferences.autosave_interval_minutes() * 60 * 1000)
52 if preferences.autosave_enabled():
53 timer.start()
54 else:
55 timer.stop()
57 def request_save(self):
58 """Capture on the UI thread; compress and write outside the event loop."""
59 if getattr(self.window, "_closing", False):
60 return
61 if self._pending is not None and not self._pending.done():
62 return
63 if self.window.canvas.drawing:
64 # Do not capture a half-finished stroke or share an active painter.
65 QTimer.singleShot(1000, self.request_save)
66 return
67 try:
68 metadata = copy.deepcopy(self.window.build_project_metadata())
69 frames = [frame.clone() for frame in self.window.canvas.frames]
70 sequence_archive = {
71 key: layer.clone()
72 for key, layer in self.window.canvas._sequence_archive.items()
73 }
74 path = self.path()
75 if self._executor is None: 75 ↛ 81line 75 didn't jump to line 81 because the condition on line 75 was always true
76 self._executor = ThreadPoolExecutor(
77 max_workers=1, thread_name_prefix="pma-autosave"
78 )
79 # Cloned QImages share pixels until an edit detaches them. The worker
80 # owns its PNG cache, so manual saves/opens cannot race with it.
81 self._pending = self._executor.submit(
82 self._write, path, metadata, frames, sequence_archive, self._png_cache
83 )
84 except Exception:
85 log.exception("autosave snapshot failed")
87 @staticmethod
88 def _write(path, metadata, frames, sequence_archive, png_cache):
89 try:
90 project_io.write_project_archive(
91 path, metadata, frames, sequence_archive, png_cache
92 )
93 except Exception:
94 log.exception("autosave failed")
96 def save(self):
97 # Explicit synchronous saves must not collide with a periodic write.
98 if self._pending is not None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 self._pending.result()
100 # Must never raise into the event loop — autosave is best-effort.
101 try:
102 project_io.write_project_archive(
103 self.path(),
104 self.window.build_project_metadata(),
105 self.window.canvas.frames,
106 self.window.canvas._sequence_archive,
107 self.window.png_cache,
108 )
109 except Exception: # noqa: BLE001 - best-effort; must never raise into the event loop
110 # Autosave is best-effort and must never raise into the event loop,
111 # so the broad catch is intentional; log the traceback instead of
112 # printing it so it lands in the app log.
113 log.exception("autosave failed")
115 def maybe_restore(self):
116 """On startup, offer to restore a leftover autosave (likely a crash)."""
117 path = self.path()
118 try:
119 if not path.exists() or path.stat().st_size == 0: 119 ↛ 123line 119 didn't jump to line 123 because the condition on line 119 was always true
120 return
121 except OSError:
122 return
123 answer = QMessageBox.question(
124 self.window,
125 tr("作業の復元"),
126 tr("前回のセッションが正常に終了しなかった可能性があります。\n"
127 "自動保存された作業を復元しますか?"),
128 )
129 if answer == QMessageBox.StandardButton.Yes:
130 self.window.project.open(str(path))
131 else:
132 self.clear()
134 def clear(self):
135 # Finish an in-flight atomic replacement before deleting the snapshot;
136 # otherwise it could reappear after a clean shutdown.
137 if self._executor is not None:
138 self._executor.shutdown(wait=True)
139 self._executor = None
140 self._pending = None
141 self._png_cache.clear()
142 try:
143 self.path().unlink(missing_ok=True)
144 except OSError:
145 # A stale snapshot can trigger another recovery prompt on the next
146 # launch, so preserve the failure details for diagnosis.
147 log.warning("failed to remove autosave snapshot: %s", self.path(), exc_info=True)