Source code for simvx.ide.widgets.confirm_dialog

"""ConfirmDialog -- modal dialog with message and action buttons."""

import logging

from simvx.core import Property, Signal
from simvx.core.input import MouseButton
from simvx.core.text.measure import text_minimum_width
from simvx.core.ui.core import Control, UIInputEvent
from simvx.core.ui.enums import AnchorPreset
from simvx.core.ui.theme import get_theme

log = logging.getLogger(__name__)

_WIDTH = 420.0
_HEIGHT = 120.0
_PAD = 12.0
_FONT_SIZE = 14.0
_BTN_H = 28.0
_BTN_PAD = 8.0
#: Text scale button labels are drawn at, slightly smaller than the dialog text.
_BTN_LABEL_SCALE = _FONT_SIZE * 0.9 / 16.0
#: Space left either side of a button label, so the text never meets the border.
_BTN_TEXT_PAD = 24.0
#: Width below which a button is widened, so short actions stay easy to hit.
_BTN_MIN_W = 80.0


def _button_width(label: str) -> float:
    """Width of the button drawn for *label*, fitted to the label's own glyphs.

    Hit-testing and drawing both size buttons through here, so the rect a click
    is tested against is the rect the user sees whatever face the label is drawn
    in. A fixed per-character advance cannot do that: on a proportional face it
    leaves ``WM`` overflowing its button and ``Il`` rattling around inside one.
    """
    return max(_BTN_MIN_W, text_minimum_width(label, _BTN_LABEL_SCALE) + _BTN_TEXT_PAD)


[docs] class ConfirmDialog(Control): """Modal dialog with a message and up to 3 buttons. Usage:: dlg = ConfirmDialog() dlg.show("Unsaved changes", "Save changes before closing?", buttons=[("Save", "save"), ("Don't Save", "discard"), ("Cancel", "cancel")]) dlg.button_pressed.connect(lambda action: print(action)) """ # A dialog is up only between show() and its answer, and it draws above # everything the IDE has on screen. The card measures itself in on_draw, so # the control itself carries no size of its own. visible = Property( False, coerce=bool, hint="Whether this node and its subtree are drawn and picked", on_change="_on_visible_changed", ) z_index = Property(3000, range=(-4096, 4096), hint="Draw order (higher = on top)", on_change="_invalidate_z_cache") size_x = Property(0.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(0.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") def __init__(self, **kwargs): super().__init__(**kwargs) # Full-screen blocking overlay with a dim scrim. The router gates input to # this subtree; outside-click and Escape both fire ``cancel_requested``. self.set_anchor_preset(AnchorPreset.FULL_RECT) self.button_pressed = Signal() self._title: str = "" self._message: str = "" self._buttons: list[tuple[str, str]] = [] # (label, action_id) self._hovered_btn: int = -1 self.cancel_requested.connect(self._on_cancel)
[docs] def show(self, title: str, message: str, buttons: list[tuple[str, str]] | None = None): """Show the dialog. Each button is ``(label, action_id)``.""" self._title = title self._message = message self._buttons = buttons or [("OK", "ok")] self._hovered_btn = -1 self.show_overlay("blocking")
[docs] def hide(self): if self.visible: self.close_overlay() self.visible = False self._buttons.clear()
def _on_cancel(self): if self.visible: self.close_overlay() self.visible = False self.button_pressed.emit("cancel") def _on_gui_input(self, event: UIInputEvent): if not self.visible: return if event.key == "enter" and event.pressed: # Enter triggers the first (primary) button if self._buttons: action = self._buttons[0][1] self.hide() self.button_pressed.emit(action) event.handled = True return # Track hover over buttons if event.position and event.button is None: self._hovered_btn = self._btn_index_at(event.position) # Click on button if event.button == MouseButton.LEFT and event.pressed and event.position: idx = self._btn_index_at(event.position) if idx >= 0: action = self._buttons[idx][1] self.hide() self.button_pressed.emit(action) event.handled = True def _btn_index_at(self, pos) -> int: """Return button index under the position, or -1.""" ss = self._get_parent_size() sw, sh = ss.x, ss.y dx = (sw - _WIDTH) / 2 dy = (sh - _HEIGHT) / 2 px = pos.x if hasattr(pos, "x") else pos[0] py = pos.y if hasattr(pos, "y") else pos[1] btn_y = dy + _HEIGHT - _BTN_H - _PAD if not (btn_y <= py <= btn_y + _BTN_H): return -1 # Buttons are right-aligned bx = dx + _WIDTH - _PAD for i in range(len(self._buttons) - 1, -1, -1): label = self._buttons[i][0] bw = _button_width(label) bx -= bw if bx <= px <= bx + bw: return i bx -= _BTN_PAD return -1
[docs] def on_draw(self, renderer): if not self.visible: return theme = get_theme() ss = self._get_parent_size() sw, sh = ss.x, ss.y scale = _FONT_SIZE / 16.0 # The "blocking" overlay's dim scrim is painted by the collector; we only # render the dialog box on top. dx = (sw - _WIDTH) / 2 dy = (sh - _HEIGHT) / 2 renderer.draw_rect((dx, dy), (_WIDTH, _HEIGHT), colour=theme.popup_bg, filled=True) renderer.draw_rect((dx, dy), (_WIDTH, _HEIGHT), colour=theme.border_light) # Title renderer.draw_text(self._title, (dx + _PAD, dy + _PAD), colour=theme.text, scale=scale) # Message msg_y = dy + _PAD + _FONT_SIZE * 1.6 renderer.draw_text(self._message, (dx + _PAD, msg_y), colour=theme.text_dim, scale=scale * 0.9) # Buttons (right-aligned, first button is "primary") btn_y = dy + _HEIGHT - _BTN_H - _PAD bx = dx + _WIDTH - _PAD for i in range(len(self._buttons) - 1, -1, -1): label = self._buttons[i][0] bw = _button_width(label) bx -= bw # Button background if i == 0: bg = theme.accent # primary action elif self._buttons[i][1] == "discard": bg = theme.btn_danger elif i == self._hovered_btn: bg = theme.btn_hover else: bg = theme.btn_bg if i == self._hovered_btn and i != 0: bg = theme.btn_hover renderer.draw_rect((bx, btn_y), (bw, _BTN_H), colour=bg, filled=True) renderer.draw_rect((bx, btn_y), (bw, _BTN_H), colour=theme.border_light) # Button label (centred), measured at the scale it is drawn at so it # sits inside the width _button_width reserved for it. tw = renderer.text_width(label, _BTN_LABEL_SCALE) tx = bx + (bw - tw) / 2 ty = btn_y + (_BTN_H - _FONT_SIZE) / 2 renderer.draw_text(label, (tx, ty), colour=theme.text, scale=_BTN_LABEL_SCALE) bx -= _BTN_PAD