Source code for simvx.editor.autosave

"""Editor autosave + crash recovery.

The editor's autosave is conceptually distinct from :class:`~simvx.core.SaveManager`:

* ``SaveManager`` snapshots only ``Property`` descriptors flagged
  ``persist=True`` -- a deliberately narrow contract for in-game state.
* The editor's autosave needs the **full scene structure** so that, after a
  crash, no newly-added nodes are lost. We therefore reuse the engine's
  in-memory scene snapshot helper (:func:`simvx.core._scene_internal._serialize_node`)
  and pickle that dict. Restoring goes through :func:`_deserialize_node`.

Both paths share :func:`simvx.core.save_manager.pickle_atomic` for the
write-tmp + fsync + rename + two-deep ``.bak``/``.bak2`` rotation, so the
crash-safety guarantees are identical.

The autosave file lives at ``$XDG_DATA_HOME/simvx/editor/autosave.sav``
(falling back to ``~/.local/share/simvx/editor/autosave.sav``). One file --
the most-recent project's working tree -- because the editor only ever has
one foreground workspace.
"""

from __future__ import annotations

import logging
import os
import pickle
import time
from pathlib import Path

from simvx.core import Button, Label, Panel, Signal, Vec2
from simvx.core._scene_internal import _deserialize_node, _serialize_node
from simvx.core.save_manager import pickle_atomic

log = logging.getLogger(__name__)


def _default_autosave_dir() -> Path:
    """Return ``$XDG_DATA_HOME/simvx/editor`` or the standard fallback."""
    xdg = os.environ.get("XDG_DATA_HOME")
    base = Path(xdg) if xdg else Path.home() / ".local" / "share"
    return base / "simvx" / "editor"


[docs] class Autosave: """Periodic full-tree autosave with crash-recovery on launch. Polled each frame via :meth:`poll`. When the autosave interval has elapsed and the editor's scene is marked modified, a pickle of :func:`_serialize_node(scene_root)` is written atomically to ``<autosave_dir>/autosave.sav``. On startup, :meth:`check_recovery` returns the autosave envelope when it is newer than the most-recently opened project file (if any). """ INTERVAL = 60.0 # seconds def __init__(self, state, autosave_dir: Path | None = None): self._state = state self._last_save = 0.0 self.autosave_dir: Path = autosave_dir if autosave_dir is not None else _default_autosave_dir() # ------------------------------------------------------------------ # Paths # ------------------------------------------------------------------
[docs] @property def autosave_path(self) -> Path: """Single canonical autosave file path.""" return self.autosave_dir / "autosave.sav"
# ------------------------------------------------------------------ # Frame polling # ------------------------------------------------------------------
[docs] def poll(self, now: float = 0.0) -> None: """Save the working tree if the interval has elapsed and it's dirty.""" if not now: now = time.monotonic() if now - self._last_save < self.INTERVAL: return if not self._state.modified: return self._save_snapshot() self._last_save = now
# ------------------------------------------------------------------ # Snapshot persistence # ------------------------------------------------------------------ def _save_snapshot(self) -> None: scene = getattr(self._state, "edited_scene", None) root = scene.root if scene is not None else None if root is None: return scene_path = self._state.current_scene_path envelope = { "format_version": 1, "timestamp": time.time(), "scene_path": str(scene_path) if scene_path else None, "scene": _serialize_node(root), } try: pickle_atomic(self.autosave_path, envelope) log.debug("Autosaved editor scene to %s", self.autosave_path) except Exception: # Reported via the standard log path -- the Console panel surfaces # these. Strict-raise is wrong here: a failed autosave must never # crash the editor or interrupt the user. log.exception("Editor autosave failed for %s", self.autosave_path) # ------------------------------------------------------------------ # Recovery # ------------------------------------------------------------------
[docs] def check_recovery(self, project_path: Path | None = None) -> dict | None: """Return the autosave envelope when recovery should be offered. Returns ``None`` when: * no autosave file exists, or * a ``project_path`` was supplied and the autosave is *older than* the project file (i.e. the project's on-disk state is already fresher). Otherwise returns the decoded envelope dict (caller decides whether to show a dialog and/or restore). """ path = self.autosave_path if not path.exists(): return None try: autosave_mtime = path.stat().st_mtime except OSError: log.warning("Could not stat autosave file %s", path, exc_info=True) return None if project_path is not None: try: project_mtime = Path(project_path).stat().st_mtime if autosave_mtime <= project_mtime: return None except OSError: # Project path missing -- treat the autosave as recoverable. pass try: envelope = pickle.loads(path.read_bytes()) except Exception as exc: # Surface to the Console panel; do not throw. The recovery prompt # will inspect ``envelope is None`` and show the error message. log.exception("Autosave file %s is corrupt: %s", path, exc) return {"_error": f"{type(exc).__name__}: {exc}", "_path": str(path)} if not isinstance(envelope, dict): log.warning("Autosave file %s has unexpected payload type %s", path, type(envelope).__name__) return None return envelope
[docs] def restore(self, envelope: dict): """Reconstruct a Node tree from a recovery envelope. The caller is responsible for swapping the returned root into the editor's active scene tab. Raises ``RuntimeError`` if the envelope is malformed (missing ``scene`` key); strict per the engine's error philosophy. """ if "_error" in envelope: raise RuntimeError(f"Cannot restore corrupt autosave: {envelope['_error']}") scene = envelope.get("scene") if not isinstance(scene, dict): raise RuntimeError("Autosave envelope missing valid 'scene' dict") scene_path = envelope.get("scene_path") scene_dir = str(Path(scene_path).parent) if scene_path else "" return _deserialize_node(scene, scene_dir=scene_dir)
[docs] def discard(self) -> None: """Delete the live autosave file (keeps ``.bak`` / ``.bak2``).""" try: self.autosave_path.unlink() except FileNotFoundError: pass except OSError: log.warning("Could not delete autosave file %s", self.autosave_path, exc_info=True)
# --------------------------------------------------------------------------- # Recovery dialog # ---------------------------------------------------------------------------
[docs] class AutosaveRecoveryDialog(Panel): """Modal prompt shown on startup when a fresh autosave is available. Three outcomes, exposed as signals: * :attr:`restore_requested` -- user wants the autosaved tree swapped in. * :attr:`discard_requested` -- delete the autosave and continue with the project file as-is. * :attr:`cancelled` -- close the dialog without acting (e.g. user wants to inspect things first; autosave is left intact for later). """ DIALOG_WIDTH = 380.0 DIALOG_HEIGHT = 180.0 BUTTON_HEIGHT = 32.0 BUTTON_GAP = 8.0 def __init__(self, **kwargs): kwargs.setdefault("name", "AutosaveRecoveryDialog") super().__init__(**kwargs) self.restore_requested = Signal() self.discard_requested = Signal() self.cancelled = Signal() self.bg_colour = (0.0, 0.0, 0.0, 0.6) self.border_width = 0 self.visible = False self._dialog_panel: Panel | None = None self._message_label: Label | None = None self._build() def _build(self) -> None: inner = Panel(name="DialogInner") inner.bg_colour = (0.18, 0.18, 0.20, 1.0) inner.border_colour = (0.35, 0.35, 0.4, 1.0) inner.border_width = 1.0 inner.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT) self._dialog_panel = inner title = Label("Recover Autosave?", name="Title") title.font_size = 14.0 title.text_colour = (0.9, 0.9, 0.9, 1.0) title.position = Vec2(16, 12) inner.add_child(title) msg = Label("", name="Message") msg.font_size = 12.0 msg.text_colour = (0.7, 0.7, 0.7, 1.0) msg.position = Vec2(16, 44) self._message_label = msg inner.add_child(msg) btn_y = self.DIALOG_HEIGHT - self.BUTTON_HEIGHT - 16 btn_w = (self.DIALOG_WIDTH - 16 * 2 - self.BUTTON_GAP * 2) / 3 restore_btn = Button("Restore", name="RestoreBtn") restore_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) restore_btn.position = Vec2(16, btn_y) restore_btn.bg_colour = (0.20, 0.57, 0.92, 1.0) restore_btn.pressed.connect(self._on_restore) inner.add_child(restore_btn) discard_btn = Button("Discard", name="DiscardBtn") discard_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) discard_btn.position = Vec2(16 + btn_w + self.BUTTON_GAP, btn_y) discard_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) discard_btn.pressed.connect(self._on_discard) inner.add_child(discard_btn) cancel_btn = Button("Open Anyway", name="CancelBtn") cancel_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) cancel_btn.position = Vec2(16 + (btn_w + self.BUTTON_GAP) * 2, btn_y) cancel_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) cancel_btn.pressed.connect(self._on_cancel) inner.add_child(cancel_btn) self.add_child(inner)
[docs] def show_dialog(self, message: str, parent_size: Vec2 | None = None) -> None: """Reveal the dialog with the given ``message`` body.""" self.visible = True if self._message_label: self._message_label.text = message if parent_size: self.size = parent_size if self._dialog_panel: pw = self.size.x if self.size.x > 0 else 400 ph = self.size.y if self.size.y > 0 else 300 dw, dh = self._dialog_panel.size.x, self._dialog_panel.size.y self._dialog_panel.position = Vec2((pw - dw) / 2, (ph - dh) / 2)
def _on_restore(self) -> None: self.visible = False self.restore_requested.emit() def _on_discard(self) -> None: self.visible = False self.discard_requested.emit() def _on_cancel(self) -> None: self.visible = False self.cancelled.emit()
[docs] def gui_input(self, event): if hasattr(event, "key") and event.key == "escape" and event.pressed: self._on_cancel() return True return super().gui_input(event) if hasattr(super(), "gui_input") else False
[docs] def format_recovery_message(envelope: dict) -> str: """Build the human-readable message body for :class:`AutosaveRecoveryDialog`.""" if "_error" in envelope: return f"Autosave file is corrupt:\n{envelope['_error']}\n\nDiscard to remove it." ts = envelope.get("timestamp", 0.0) when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else "unknown time" scene = envelope.get("scene_path") or "(unsaved scene)" return f"Autosave from {when} is newer than your project.\nScene: {scene}\nRestore the autosaved tree?"