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