Source code for simvx.ide.widgets.confirm_dialog

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

import logging

from simvx.core import PointerFilter, Property, Signal
from simvx.core.input import Key, KeyEvent, Pointer, PointerEvent, PointerPhase, ScrollEvent
from simvx.core.text.measure import text_minimum_width
from simvx.core.ui.core import Control
from simvx.core.ui.enums import Anchor
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)) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") top_level = Property(True, on_change="_invalidate_transform", hint="Placed on the screen, not by its parent") # 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") anchor = Property(Anchor.FULL_RECT, group="Layout", hint="A full-screen overlay unless the caller places it") 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.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")
[docs] def on_gui_input(self, event): if not self.visible: return if isinstance(event, PointerEvent): # Track hover over buttons if event.phase is PointerPhase.MOVE: self._hovered_btn = self._btn_index_at(event.position) # Click on button if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: idx = self._btn_index_at(event.position) if idx >= 0: action = self._buttons[idx][1] self.hide() self.button_pressed.emit(action) return True return None if isinstance(event, ScrollEvent): return None if isinstance(event, KeyEvent) and event.key is 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) return True return None
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.rect((dx, dy), (_WIDTH, _HEIGHT), fill=theme.popup_bg) renderer.rect((dx, dy), (_WIDTH, _HEIGHT), stroke=theme.border_light) # Title renderer.text(self._title, (dx + _PAD, dy + _PAD), fill=theme.text, scale=scale) # Message msg_y = dy + _PAD + _FONT_SIZE * 1.6 renderer.text(self._message, (dx + _PAD, msg_y), fill=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.rect((bx, btn_y), (bw, _BTN_H), fill=bg) renderer.rect((bx, btn_y), (bw, _BTN_H), stroke=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.text(label, (tx, ty), fill=theme.text, scale=_BTN_LABEL_SCALE) bx -= _BTN_PAD