Source code for simvx.editor.save_report_dialog

"""The prompt shown when a save would rewrite the author's own ``__init__``.

Most saves write the scene back into the author's file and nothing else, and
those go through in silence. Some cannot: a file that builds children in a loop,
a conditional or a helper method has no statement the save can match against the
scene, so those children are written out a second time, and removing a child
takes the author's statements that stood on it. Those are what this asks about.

A value with no source form, or one the file spells as an expression this save
will not overwrite, is the other class: the line stays exactly as the author
wrote it, nothing of theirs is lost, and the save writes and warns rather than
stopping to ask -- the answer Godot, Unity and Unreal all give. The prompt shows
those too when it opens for something else, since the user answering is owed the
whole of what the save would leave behind.

All of it is known before anything is written
(:meth:`~simvx.editor.scene_file_ops.SceneFileOps.plan_save`), so it is put in
front of the user while the file on disk is still the one they last saw. Cancel
leaves it untouched; Save Anyway commits the plan that produced the report.

One caller cannot ask first (:meth:`SaveReportDialog.show_written`): converting
a node to a class writes a class file, saves the scene and reloads it as one
action, so what it could not do is known only once all three have happened. The
same report is shown in the same words, with the one button that is left.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING

from simvx.core import (
    Button,
    FocusMode,
    HBoxContainer,
    Label,
    Panel,
    ScrollContainer,
    Signal,
    VBoxContainer,
    Vec2,
)
from simvx.core.ui.enums import AnchorPreset

from .dialog_message import message_lines
from .modal_dialog import BaseModalDialog

if TYPE_CHECKING:
    from pathlib import Path

    from .scene_diff import ReportEntry
    from .scene_file_ops import ScenePlan

__all__ = ["SaveReportDialog"]

_PAD = 20.0
_ROW_H = 30.0
_ENTRY_FONT = 12.0

#: What the two classes of entry are called on the card. The first names what
#: the save would do to the author's own file, which is what is being asked
#: about; the second names what it will leave the file saying instead.
_CHANGES_HEADING = "Saving will change these lines:"
_KEEPS_HEADING = "And these will stay as the file has them, so the scene keeps them alone:"

#: The same two headings for a save that has already been written.
_CHANGED_HEADING = "Saving changed these lines:"
_KEPT_HEADING = "And these stayed as the file has them, so the scene keeps them alone:"

#: What the card says in each of its two moods: asking about a save that has not
#: happened, and reporting one that has.
_ASK_TITLE = "This save will change the file"
_ASK_HINT = "Cancel leaves the file exactly as it is."
_WRITTEN_TITLE = "This save has changed the file"
_WRITTEN_HINT = "The file is already written. These lines are yours to finish by hand."

_SCRIM = (0.0, 0.0, 0.0, 0.55)
_BG = (0.16, 0.16, 0.18, 1.0)
_BORDER = (0.36, 0.36, 0.40, 1.0)
_TITLE = (0.95, 0.95, 0.97, 1.0)
_TEXT = (0.78, 0.78, 0.82, 1.0)
_HINT = (0.55, 0.55, 0.58, 1.0)


[docs] class SaveReportDialog(BaseModalDialog): """Modal "this save will change the file" confirmation. Construct once per editor; call :meth:`show_for` with the plan the save produced and a callback that commits it. On "Save Anyway" the callback fires; on "Cancel" the dialog hides, emits :attr:`cancelled`, and the file is left exactly as it was. """ confirmed = Signal() cancelled = Signal() DIALOG_W = 620.0 DIALOG_H = 400.0 def __init__(self, **kwargs): super().__init__(**kwargs) self.set_anchor_preset(AnchorPreset.FULL_RECT) self.z_index = 1800 # The scrim this dialog paints itself in :meth:`on_draw`, over an editor # that stays live behind it. self.bg_colour = _SCRIM self._on_confirm: Callable[[], None] | None = None # Which pair of headings the entries go under, which is which mood the # card is in: see :meth:`_ask`. self._headings: tuple[str, str] = (_CHANGES_HEADING, _KEEPS_HEADING) self._inner = Panel(name="SaveReportInner") self._inner.bg_colour = _BG self._inner.border_colour = _BORDER self._inner.border_width = 1.0 self._inner.size = Vec2(self.DIALOG_W, self.DIALOG_H) self.add_child(self._inner) body = VBoxContainer(name="SaveReportBody") body.separation = 10 body.position = Vec2(_PAD, _PAD) body.size = Vec2(self.DIALOG_W - 2 * _PAD, self.DIALOG_H - 2 * _PAD) self._inner.add_child(body) self._title_label = Label(_ASK_TITLE, name="Title") self._title_label.font_size = 16.0 self._title_label.text_colour = _TITLE self._title_label.size = Vec2(self.DIALOG_W - 2 * _PAD, 22) body.add_child(self._title_label) self._target_label = Label("", name="Target") self._target_label.font_size = 12.0 self._target_label.text_colour = _TEXT self._target_label.size = Vec2(self.DIALOG_W - 2 * _PAD, 18) body.add_child(self._target_label) self._entries = ScrollContainer(name="Entries") self._entries.bg_colour = _BG self._entries.separation = 0 self._entries.focus_mode = FocusMode.ALL self._entries.size = Vec2(self.DIALOG_W - 2 * _PAD, self.DIALOG_H - 2 * _PAD - 22 - 18 - _ROW_H - 18 - 44) body.add_child(self._entries) self._hint_label = Label(_ASK_HINT, name="Hint") self._hint_label.font_size = 11.0 self._hint_label.text_colour = _HINT self._hint_label.size = Vec2(self.DIALOG_W - 2 * _PAD, 18) body.add_child(self._hint_label) button_row = HBoxContainer(name="ButtonRow") button_row.separation = 8 button_row.size = Vec2(self.DIALOG_W - 2 * _PAD, _ROW_H + 4) # Both buttons join the Tab order (``Button`` defaults to click-only # focus), so the dialog is fully operable from the keyboard: Tab picks a # button, the ``ui_accept`` key presses it. self._cancel_btn = Button("Cancel", name="CancelBtn") self._cancel_btn.size = Vec2(120, _ROW_H) self._cancel_btn.focus_mode = FocusMode.ALL self._cancel_btn.pressed.connect(self._on_cancel) button_row.add_child(self._cancel_btn) spacer = Label("", name="Spacer") spacer.size = Vec2(self.DIALOG_W - 2 * _PAD - 120 - 150 - 16, _ROW_H) button_row.add_child(spacer) self._confirm_btn = Button("Save Anyway", name="SaveAnywayBtn") self._confirm_btn.size = Vec2(150, _ROW_H) self._confirm_btn.focus_mode = FocusMode.ALL self._confirm_btn.pressed.connect(self._on_confirm_pressed) button_row.add_child(self._confirm_btn) body.add_child(button_row) # ------------------------------------------------------------ public API
[docs] def show_for(self, plan: ScenePlan, on_confirm: Callable[[], None] | None = None) -> None: """Open on ``plan``'s report; ``on_confirm`` fires on Save Anyway. Cancel is the safe answer and the one the dialog opens on, since the alternative overwrites a file the user has not seen the terms for yet. Both classes of entry are shown, under headings that say which is which: the editor opens this only for a plan carrying a destructive one (``SceneFileOps._ask_or_commit``), and the user answering for those is owed the rest of what the save would leave behind at the same time. A plan with nothing to report is refused rather than shown. There is no question to put: the save changes nothing beyond the user's edits, and a modal offering to "Save Anyway" over an empty list asks them to accept terms nobody has stated. """ if plan.clean: raise ValueError(f"a clean plan for {plan.path.name} has nothing to ask about; commit it instead") self._ask(True) self._target_label.text = f"{plan.path.name} cannot be written back exactly as you edited it." self._show_report(plan.report) self._on_confirm = on_confirm self.open_modal(initial_focus=self._cancel_btn)
[docs] def show_written(self, path: Path, report: Sequence[ReportEntry]) -> None: """Open on what a save has already done, where there is nothing left to ask. Every entry normally reaches the user before the write, which is what planning a save is for. Converting a node to a class cannot: it writes a class file, saves the scene and reloads it as one action, so what the save could not do is known only once the file on disk is the new one -- and the worst of those, a class file written that the scene was never pointed at, leaves the user with lines only they can finish. That is worth the same card the prompt uses, in the same words; what a log line buys is a user who thinks it worked. There is no question, so there is no Save Anyway. A report with nothing in it is refused, as an empty prompt is. """ entries = list(report) if not entries: raise ValueError(f"{path.name} has nothing to report; there is nothing to show") self._ask(False) self._target_label.text = f"{path.name} is written, and could not carry all of it." self._show_report(entries) self._on_confirm = None self.open_modal(initial_focus=self._cancel_btn)
[docs] def dismiss(self) -> None: """Hide the dialog and forget the plan it was asking about.""" super().dismiss() self._on_confirm = None
# --------------------------------------------------------------- internals def _ask(self, asking: bool) -> None: """Dress the card as a question, or as a report of what already happened.""" self._title_label.text = _ASK_TITLE if asking else _WRITTEN_TITLE self._hint_label.text = _ASK_HINT if asking else _WRITTEN_HINT self._cancel_btn.text = "Cancel" if asking else "Close" self._confirm_btn.visible = asking self._headings = (_CHANGES_HEADING, _KEEPS_HEADING) if asking else (_CHANGED_HEADING, _KEPT_HEADING) def _show_report(self, report: Sequence[ReportEntry]) -> None: """Split a report into its two classes and put both on the card.""" self._set_entries( [entry for entry in report if entry.destructive], [entry for entry in report if not entry.destructive], ) def _set_entries(self, destructive: list[ReportEntry], informational: list[ReportEntry]) -> None: """Show the report, one line per wrapped line, scrolled rather than cut. The user is being asked whether to write the file on these terms, so all of them have to be readable: a report longer than the card scrolls instead of losing its middle. Each wrapped line is its own row, spanning the wrapped width and as tall as the row itself asks to be, and the stack of them is what the scroll area measures its content height from. The two classes are shown under headings of their own, and the ones that change the file come first: they are what the question is about, and the rest is what the same save would leave behind either way. """ for row in list(self._entries.children): self._entries.remove_child(row) self._entries.scroll_y = 0.0 changes_heading, keeps_heading = self._headings blocks: list[str] = [] if destructive: blocks.append(changes_heading) blocks.extend(f"- {entry}" for entry in destructive) if informational: if blocks: blocks.append("") blocks.append(keeps_heading) blocks.extend(f"- {entry}" for entry in informational) text = "\n".join(blocks) for line in message_lines(text, self._entry_width(), _ENTRY_FONT) if text else []: row = Label(line) row.font_size = _ENTRY_FONT row.text_colour = _TEXT row.size = Vec2(self._entry_width(), row.get_minimum_size().y) self._entries.add_child(row) self._entries.mark_layout_dirty() def _entry_width(self) -> float: """Width a report line is wrapped to: the viewport minus the scroll gutter. The gutter is subtracted whether or not the scrollbar is showing, so a report does not re-wrap as it grows past the bottom of the card. """ return float(self._entries.size.x) - float(self._entries.scrollbar_width)
[docs] @property def report_text(self) -> str: """The whole report as the dialog is showing it, wrapped as drawn.""" return "\n".join(str(row.text) for row in self._entries.children)
# --------------------------------------------------------------- buttons def _on_confirm_pressed(self) -> None: callback = self._on_confirm self.dismiss() self.confirmed.emit() if callback is not None: callback() def _on_cancel(self) -> None: self.dismiss() self.cancelled.emit() # --------------------------------------------------------------- rendering
[docs] def on_draw(self, renderer): """The scrim, plus the card centred in whatever the window is now.""" if not self.visible: return size = self._get_parent_size() renderer.draw_rect((0, 0), (size.x, size.y), colour=self.bg_colour, filled=True) self._inner.position = Vec2((size.x - self.DIALOG_W) / 2, (size.y - self.DIALOG_H) / 2)