"""ConfirmDialog -- modal dialog with message and action buttons."""
import logging
from simvx.core import Signal, Vec2
from simvx.core.input import MouseButton
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
[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))
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Full-screen modal with a dim backdrop. The router gates input to this
# subtree; outside-click and Escape both fire ``cancel_requested``.
self.set_anchor_preset(AnchorPreset.FULL_RECT)
self.modal = True
self.dismiss_on_outside_click = True
self.pause_tree_when_modal = True
self.top_level = True
self.visible = False
self.z_index = 3000
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.size = Vec2(0, 0)
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_modal()
[docs]
def hide(self):
if self.visible:
self.close_modal()
self._buttons.clear()
def _on_cancel(self):
if self.visible:
self.close_modal()
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 = max(80.0, len(label) * _FONT_SIZE * 0.6 + 24)
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 / 14.0
# The auto-injected ``DimBackdrop`` already paints the overlay; 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 = max(80.0, len(label) * _FONT_SIZE * 0.6 + 24)
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 (centered)
tw = renderer.text_width(label, scale * 0.9)
tx = bx + (bw - tw) / 2
ty = btn_y + (_BTN_H - _FONT_SIZE) / 2
renderer.draw_text(label, (tx, ty), colour=theme.text, scale=scale * 0.9)
bx -= _BTN_PAD