Source code for simvx.editor.save_dialog

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