"""Save Preview Dialog -- Modal code preview for CST ambiguity resolution.
When the CST codegen cannot fully automate a scene-to-Python save (e.g.,
procedural code, complex expressions), this dialog shows the generated
source for the user to review, edit, and accept before writing to disk.
"""
import logging
from simvx.core import Button, CodeTextEdit, Control, FocusMode, Label, Panel, Signal, Vec2
log = logging.getLogger(__name__)
__all__ = ["SavePreviewDialog"]
[docs]
class SavePreviewDialog(Panel):
"""Modal dialog showing generated Python code before saving.
Shows a diff-style preview of changes. User can:
- Accept (save as-is)
- Edit (modify the code in an inline editor)
- Cancel (don't save)
Signals:
accepted: emits (final_source: str,) when user accepts the code.
cancelled: emitted when user cancels the save.
"""
DIALOG_WIDTH = 700.0
DIALOG_HEIGHT = 500.0
HEADER_HEIGHT = 36.0
BUTTON_HEIGHT = 32.0
BUTTON_GAP = 8.0
MARGIN = 16.0
def __init__(self, original_source: str, generated_source: str, **kwargs):
super().__init__(name="SavePreviewDialog", **kwargs)
self.accepted = Signal()
self.cancelled = Signal()
self.bg_colour = (0.0, 0.0, 0.0, 0.6)
self.border_width = 0
self.visible = False
self._original_source = original_source
self._generated_source = generated_source
self._editing = False
self._dialog_panel: Panel | None = None
self._code_editor: CodeTextEdit | None = None
self._edit_btn: Button | None = None
self._accept_btn: Button | None = None
self._build()
def _build(self):
m = self.MARGIN
dw, dh = self.DIALOG_WIDTH, self.DIALOG_HEIGHT
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(dw, dh)
self._dialog_panel = inner
# Title
title = Label("Save Preview", name="Title")
title.font_size = 15.0
title.text_colour = (0.9, 0.9, 0.9, 1.0)
title.position = Vec2(m, 10)
inner.add_child(title)
# Subtitle / description
desc = Label("Review the generated Python source before saving.", name="Description")
desc.font_size = 11.0
desc.text_colour = (0.6, 0.6, 0.6, 1.0)
desc.position = Vec2(m, 30)
inner.add_child(desc)
# Code editor area
code_top = self.HEADER_HEIGHT + m
code_h = dh - code_top - self.BUTTON_HEIGHT - m * 2
editor = CodeTextEdit(self._generated_source, name="CodePreview")
editor.position = Vec2(m, code_top)
editor.size = Vec2(dw - m * 2, code_h)
editor.read_only = True
self._code_editor = editor
inner.add_child(editor)
# Buttons row at bottom
btn_y = dh - self.BUTTON_HEIGHT - m
btn_w = (dw - m * 2 - self.BUTTON_GAP * 2) / 3
# The three buttons join the Tab order (``Button`` defaults to click-only
# focus), so the dialog is operable without a mouse: Tab picks one, the
# ``ui_accept`` key presses it. The code view stays click-focus, so Tab
# cycles the actions rather than falling into the editor.
accept_btn = Button("Accept", name="AcceptBtn")
accept_btn.focus_mode = FocusMode.ALL
accept_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
accept_btn.position = Vec2(m, btn_y)
accept_btn.bg_colour = (0.20, 0.57, 0.92, 1.0)
accept_btn.pressed.connect(self._on_accept)
self._accept_btn = accept_btn
inner.add_child(accept_btn)
edit_btn = Button("Edit", name="EditBtn")
edit_btn.focus_mode = FocusMode.ALL
edit_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
edit_btn.position = Vec2(m + btn_w + self.BUTTON_GAP, btn_y)
edit_btn.bg_colour = (0.30, 0.30, 0.33, 1.0)
edit_btn.pressed.connect(self._on_edit)
self._edit_btn = edit_btn
inner.add_child(edit_btn)
cancel_btn = Button("Cancel", name="CancelBtn")
cancel_btn.focus_mode = FocusMode.ALL
cancel_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
cancel_btn.position = Vec2(m + (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]
@property
def source(self) -> str:
"""Current source text (may have been edited by the user)."""
return self._code_editor.text if self._code_editor else self._generated_source
[docs]
@property
def editing(self) -> bool:
"""Whether the user is in edit mode."""
return self._editing
[docs]
def show(self, parent_size: Vec2 | None = None):
"""Show the dialog, centering within parent_size if given."""
self.visible = True
if parent_size:
self.size = parent_size
if self._dialog_panel:
pw = self.size.x if self.size.x > 0 else 800
ph = self.size.y if self.size.y > 0 else 600
dw, dh = self._dialog_panel.size.x, self._dialog_panel.size.y
self._dialog_panel.position = Vec2((pw - dw) / 2, (ph - dh) / 2)
# Focus the primary action so the dialog answers the keyboard immediately;
# the router only routes keys once something owns focus.
if self._accept_btn is not None:
self._accept_btn.grab_focus()
def _hide(self):
"""Hide the dialog and hand the keyboard back.
Hiding a control does not release focus, and this dialog is a plain
hidden Panel rather than an overlay, so nothing restores focus for it.
Left alone, the button that was focused on open stays the tree's focus
owner while invisible and the editor's keys keep going to a dialog that
is no longer on screen.
"""
for node in self.walk(include_self=True):
if isinstance(node, Control) and node.has_focus():
node.release_focus()
break
self.visible = False
def _on_accept(self):
self._hide()
self.accepted.emit(self.source)
def _on_edit(self):
if not self._editing:
self._editing = True
if self._code_editor:
self._code_editor.read_only = False
if self._edit_btn:
self._edit_btn.text = "Editing..."
self._edit_btn.bg_colour = (0.20, 0.57, 0.92, 0.6)
def _on_cancel(self):
self._hide()
self.cancelled.emit()
def _on_gui_input(self, event):
"""Handle escape to cancel."""
if hasattr(event, "key") and event.key == "escape" and event.pressed:
self._on_cancel()
return
super()._on_gui_input(event)