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