Quellcode für miniworlds.worlds.dialog

from __future__ import annotations

import logging
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any

import pygame


logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class DialogButton:
    rect: pygame.Rect
    label: str
    value: Any
    index: int


class Overlay:
    """Modal overlay drawn over a world's camera viewport."""

    blocks_input = True

    def __init__(
        self,
        world,
        *,
        darken: bool = True,
        overlay_color: tuple[int, int, int, int] = (0, 0, 0, 130),
    ) -> None:
        self.world = world
        self.darken = darken
        self.overlay_color = overlay_color
        self.is_open = True
        self._overlay_surface: pygame.Surface | None = None

    @property
    def viewport_rect(self) -> pygame.Rect:
        return self.world.camera.get_screen_rect()

    def close(self, value=None, notify: bool = True) -> None:
        del notify
        self.is_open = False
        if getattr(self.world, "_active_dialog", None) is self:
            self.world._active_dialog = None

    def draw(self, target_surface: pygame.Surface) -> None:
        if not self.is_open:
            return
        if self.darken:
            world_rect = self.viewport_rect
            if self._overlay_surface is None or self._overlay_surface.get_size() != world_rect.size:
                self._overlay_surface = pygame.Surface(world_rect.size, pygame.SRCALPHA)
                self._overlay_surface.fill(self.overlay_color)
            target_surface.blit(self._overlay_surface, world_rect.topleft)

    def handle_event(self, event: str, data) -> bool:
        if not self.is_open or not self.blocks_input:
            return False
        return (
            event.startswith("mouse_")
            or event.startswith("key_")
            or event.startswith("wheel_")
            or event.startswith("text_")
        )


[Doku] class Dialog(Overlay): """Modal dialog drawn over a world. Dialogs are event-loop friendly: opening a dialog returns immediately. The selected value is stored in ``value`` and can also be delivered through ``callback``. """ _INPUT_OK = object() _BUTTON_HEIGHT = 32 _BUTTON_GAP = 8 _PADDING = 20 _MIN_WIDTH = 260 _MAX_WIDTH = 500 _VALID_KINDS = ("yn", "choice", "input", "msg") _KEY_REPEAT_DELAY = 400 _KEY_REPEAT_INTERVAL = 40 _font_cache: dict[int, pygame.font.Font] = {} def __init__( self, world, message: str = "", title: str = "", choices: Sequence[str] | None = None, kind: str = "choice", default: str = "", callback: Callable[[Any], None] | None = None, position: tuple[int, int] | None = None, size: tuple[int, int] | None = None, darken: bool = True, overlay_color: tuple[int, int, int, int] = (0, 0, 0, 130), pause: bool = False, ) -> None: super().__init__(world, darken=darken, overlay_color=overlay_color) if kind not in self._VALID_KINDS: raise ValueError( f"Unknown dialog kind {kind!r}. Valid kinds are: {', '.join(self._VALID_KINDS)}" ) self.message = str(message) self.title = str(title) self.choices = [str(choice) for choice in (choices or [])] if kind == "yn" and len(self.choices) != 2: raise ValueError("A yes/no dialog needs exactly two choices") if kind == "choice" and not self.choices: raise ValueError("A choice dialog needs at least one choice") if kind == "msg" and not self.choices: self.choices = ["OK"] self.kind = kind self.input_text = str(default) self.cursor_pos = len(self.input_text) self.callback = callback self.position = position self.requested_size = size self.pause = pause self.value = None self.focus_index = 0 self.scroll_offset = 0 self._was_running: bool | None = None self._previous_key_repeat: tuple[int, int] | None = None self._buttons: list[DialogButton] = [] self._input_rect = pygame.Rect(0, 0, 0, 0) self._panel_rect = pygame.Rect(0, 0, 0, 0) self._choice_area_rect = pygame.Rect(0, 0, 0, 0) self._message_lines: list[str] = [] self._choice_buttons_visible = 0 self._pressed_button_index: int | None = None # True while the mouse press that was already active when the dialog # opened is still held; that press must not select a dialog button. self._suppress_active_press = False self._layout_cache_key: tuple | None = None self._font = self._get_font(22) self._title_font = self._get_font(28) self._button_font = self._get_font(22) @classmethod def _get_font(cls, font_size: int) -> pygame.font.Font: font = cls._font_cache.get(font_size) if font is None: font = pygame.font.Font(None, font_size) cls._font_cache[font_size] = font return font @property def _button_rects(self) -> list[tuple[pygame.Rect, str, Any]]: return [(button.rect, button.label, button.value) for button in self._buttons]
[Doku] def on_opened(self) -> None: """Called by DialogService once the dialog became the active dialog.""" if self.pause: self._was_running = self.world.is_running self.world.is_running = False if self.kind == "input": self._enable_text_entry()
[Doku] def close(self, value=None, notify: bool = True) -> None: self.value = value if self.pause and self._was_running is not None: self.world.is_running = self._was_running self._was_running = None if self.kind == "input": self._disable_text_entry() super().close(value, notify=notify) if notify and self.callback: try: self.callback(value) except Exception: logger.exception( "Error in dialog callback for dialog %r", self.title or self.message, )
def _enable_text_entry(self) -> None: try: self._previous_key_repeat = pygame.key.get_repeat() pygame.key.set_repeat(self._KEY_REPEAT_DELAY, self._KEY_REPEAT_INTERVAL) pygame.key.start_text_input() except pygame.error: self._previous_key_repeat = None def _disable_text_entry(self) -> None: if self._previous_key_repeat is None: return try: pygame.key.set_repeat(*self._previous_key_repeat) except pygame.error: pass self._previous_key_repeat = None
[Doku] def draw(self, target_surface: pygame.Surface) -> None: if not self.is_open: return super().draw(target_surface) panel_rect = self._layout(self.viewport_rect) pygame.draw.rect(target_surface, (245, 247, 250), panel_rect, border_radius=8) pygame.draw.rect(target_surface, (52, 62, 74), panel_rect, 2, border_radius=8) self._draw_title_and_message(target_surface) if self.kind == "input": self._draw_input(target_surface) if self.kind == "choice": self._draw_choice_area(target_surface) for button in self._buttons: self._draw_button(target_surface, button)
[Doku] def handle_event(self, event: str, data) -> bool: if not super().handle_event(event, data): return False if event == "mouse_left_down": self._handle_mouse_left_down(data) elif event == "mouse_left_up": self._handle_mouse_left_up(data) elif event == "key_down": self._handle_key_down(data or []) elif event == "text_input": self._handle_text_input(data) elif event == "wheel_up": self._move_focus(-1) elif event == "wheel_down": self._move_focus(1) return True
def _handle_mouse_left_down(self, pos) -> None: if self._suppress_active_press: return button = self._button_at(pos) self._pressed_button_index = button.index if button else None if button: self.focus_index = button.index def _handle_mouse_left_up(self, pos) -> None: if self._suppress_active_press: self._suppress_active_press = False self._pressed_button_index = None return button = self._button_at(pos) pressed_index = self._pressed_button_index self._pressed_button_index = None if button is None or button.index != pressed_index: return self.focus_index = button.index self._choose_focused_button() def _button_at(self, pos) -> DialogButton | None: if pos is None: return None self._ensure_layout() for button in self._buttons: if button.rect.collidepoint(pos): return button return None def _ensure_layout(self) -> None: if not self._buttons: # Force a rebuild: the cache may match the viewport even though the # buttons were cleared (e.g. between layout and the first click). self._layout_cache_key = None self._layout(self.viewport_rect) def _handle_key_down(self, keys: Sequence[str]) -> None: if "ESC" in keys: self.close(None) return if "RETURN" in keys or "ENTER" in keys: if self.kind == "input" and self.focus_index == 0: self.close(self.input_text) else: self._choose_focused_button() return if self.kind == "input" and self._handle_input_key(keys): return if "TAB" in keys or "DOWN" in keys or "RIGHT" in keys: self._move_focus(1) return if "UP" in keys or "LEFT" in keys: self._move_focus(-1) return def _handle_input_key(self, keys: Sequence[str]) -> bool: """Handles editing keys for input dialogs. Returns True if handled.""" if "BACKSPACE" in keys: if self.cursor_pos > 0: self.input_text = ( self.input_text[: self.cursor_pos - 1] + self.input_text[self.cursor_pos:] ) self.cursor_pos -= 1 return True if "DELETE" in keys: self.input_text = ( self.input_text[: self.cursor_pos] + self.input_text[self.cursor_pos + 1:] ) return True if "LEFT" in keys: self.cursor_pos = max(0, self.cursor_pos - 1) return True if "RIGHT" in keys: self.cursor_pos = min(len(self.input_text), self.cursor_pos + 1) return True if "HOME" in keys or "home" in keys: self.cursor_pos = 0 return True if "END" in keys or "end" in keys: self.cursor_pos = len(self.input_text) return True return False def _handle_text_input(self, text) -> None: if self.kind != "input" or not text: return text = str(text) self.input_text = ( self.input_text[: self.cursor_pos] + text + self.input_text[self.cursor_pos:] ) self.cursor_pos += len(text) def _move_focus(self, step: int) -> None: count = self._button_count() if count == 0: return self.focus_index = (self.focus_index + step) % count self._ensure_focus_visible() def _choose_focused_button(self) -> None: if self.kind == "choice": if 0 <= self.focus_index < len(self.choices): self.close(self.choices[self.focus_index]) return if self.kind == "yn": self.close(self.focus_index == 0) return if self.kind == "msg": self.close(True) return if self.kind == "input": self.close(self.input_text if self.focus_index == 0 else None) def _button_count(self) -> int: if self.kind == "choice": return len(self.choices) if self.kind == "msg": return 1 return 2 def _ensure_focus_visible(self) -> None: if self.kind != "choice" or self._choice_buttons_visible <= 0: return if self.focus_index < self.scroll_offset: self.scroll_offset = self.focus_index elif self.focus_index >= self.scroll_offset + self._choice_buttons_visible: self.scroll_offset = self.focus_index - self._choice_buttons_visible + 1 max_offset = max(0, len(self.choices) - self._choice_buttons_visible) self.scroll_offset = max(0, min(self.scroll_offset, max_offset)) def _layout(self, world_rect: pygame.Rect) -> pygame.Rect: cache_key = ( world_rect.x, world_rect.y, world_rect.width, world_rect.height, self.scroll_offset, ) if cache_key == self._layout_cache_key: return self._panel_rect width, height = self.requested_size or self._default_size(world_rect) width = min(width, max(140, world_rect.width - 24)) height = min(height, max(120, world_rect.height - 24)) if self.position is None: x = world_rect.x + (world_rect.width - width) // 2 y = world_rect.y + (world_rect.height - height) // 2 else: x = world_rect.x + self.position[0] y = world_rect.y + self.position[1] x = max(world_rect.x + 8, min(x, world_rect.right - width - 8)) y = max(world_rect.y + 8, min(y, world_rect.bottom - height - 8)) self._panel_rect = pygame.Rect(x, y, width, height) self._layout_content() # _layout_content may clamp scroll_offset; cache the final value. self._layout_cache_key = cache_key[:4] + (self.scroll_offset,) return self._panel_rect def _default_size(self, world_rect: pygame.Rect) -> tuple[int, int]: width = min(max(self._MIN_WIDTH, int(world_rect.width * 0.68)), self._MAX_WIDTH) wrap_width = width - 2 * self._PADDING message_lines = self._wrap_text(self.message, wrap_width, self._font) title_height = self._title_font.get_height() + 12 if self.title else 0 message_height = len(message_lines) * (self._font.get_height() + 4) controls_height = self._controls_height(world_rect) control_gap = 10 if self.kind in {"choice", "input"} else 0 height = self._PADDING * 2 + title_height + message_height + control_gap + controls_height return width, max(150, height) def _controls_height(self, world_rect: pygame.Rect) -> int: if self.kind == "input": return 88 if self.kind == "choice": visible_rows = min(max(1, len(self.choices)), self._max_choice_rows(world_rect)) return visible_rows * self._BUTTON_HEIGHT + max(0, visible_rows - 1) * self._BUTTON_GAP return self._BUTTON_HEIGHT def _max_choice_rows(self, world_rect: pygame.Rect) -> int: return max(1, min(8, (world_rect.height - 150) // (self._BUTTON_HEIGHT + self._BUTTON_GAP))) def _layout_content(self) -> None: panel = self._panel_rect self._buttons.clear() y = panel.y + self._PADDING if self.title: y += self._title_font.get_height() + 12 self._message_lines = self._wrap_text(self.message, panel.width - 2 * self._PADDING, self._font) y += len(self._message_lines) * (self._font.get_height() + 4) if self.kind == "input": self._layout_input_controls(panel) elif self.kind == "choice": self._layout_choice_controls(panel, y + 10) elif self.kind == "msg": self._layout_bottom_buttons([(self.choices[0], True)]) else: self._layout_bottom_buttons([(self.choices[0], True), (self.choices[1], False)]) def _layout_input_controls(self, panel: pygame.Rect) -> None: self._input_rect = pygame.Rect(panel.x + self._PADDING, panel.bottom - 88, panel.width - 2 * self._PADDING, 34) self._layout_bottom_buttons([("OK", self._INPUT_OK), ("Cancel", None)]) def _layout_choice_controls(self, panel: pygame.Rect, top: int) -> None: available_height = max( self._BUTTON_HEIGHT, panel.bottom - self._PADDING - top, ) self._choice_buttons_visible = max( 1, min( len(self.choices), (available_height + self._BUTTON_GAP) // (self._BUTTON_HEIGHT + self._BUTTON_GAP), ), ) self._ensure_focus_visible() button_width = panel.width - 2 * self._PADDING height = ( self._choice_buttons_visible * self._BUTTON_HEIGHT + max(0, self._choice_buttons_visible - 1) * self._BUTTON_GAP ) self._choice_area_rect = pygame.Rect(panel.x + self._PADDING, top, button_width, height) y = top for index in range(self.scroll_offset, self.scroll_offset + self._choice_buttons_visible): if index >= len(self.choices): break self._buttons.append( DialogButton( pygame.Rect(panel.x + self._PADDING, y, button_width, self._BUTTON_HEIGHT), self.choices[index], self.choices[index], index, ) ) y += self._BUTTON_HEIGHT + self._BUTTON_GAP def _layout_bottom_buttons(self, buttons: Sequence[tuple[str, Any]]) -> None: panel = self._panel_rect button_width = min(130, max(76, (panel.width - 2 * self._PADDING - self._BUTTON_GAP) // 2)) total_width = len(buttons) * button_width + max(0, len(buttons) - 1) * self._BUTTON_GAP x = panel.x + (panel.width - total_width) // 2 y = panel.bottom - self._PADDING - self._BUTTON_HEIGHT for index, (label, value) in enumerate(buttons): self._buttons.append( DialogButton(pygame.Rect(x, y, button_width, self._BUTTON_HEIGHT), label, value, index) ) x += button_width + self._BUTTON_GAP def _draw_title_and_message(self, target_surface: pygame.Surface) -> None: y = self._panel_rect.y + self._PADDING if self.title: title_surface = self._title_font.render(self.title, True, (25, 30, 36)) target_surface.blit(title_surface, (self._panel_rect.x + self._PADDING, y)) y += title_surface.get_height() + 12 for line in self._message_lines: line_surface = self._font.render(line, True, (25, 30, 36)) target_surface.blit(line_surface, (self._panel_rect.x + self._PADDING, y)) y += line_surface.get_height() + 4 def _draw_choice_area(self, target_surface: pygame.Surface) -> None: if len(self.choices) <= self._choice_buttons_visible: return track = pygame.Rect(self._choice_area_rect.right - 4, self._choice_area_rect.y, 4, self._choice_area_rect.height) pygame.draw.rect(target_surface, (211, 218, 226), track, border_radius=2) thumb_height = max(12, int(track.height * self._choice_buttons_visible / len(self.choices))) max_offset = max(1, len(self.choices) - self._choice_buttons_visible) thumb_y = track.y + int((track.height - thumb_height) * self.scroll_offset / max_offset) pygame.draw.rect(target_surface, (87, 98, 111), (track.x, thumb_y, track.width, thumb_height), border_radius=2) def _draw_input(self, target_surface: pygame.Surface) -> None: pygame.draw.rect(target_surface, (255, 255, 255), self._input_rect, border_radius=4) pygame.draw.rect(target_surface, (63, 93, 132), self._input_rect, 2, border_radius=4) inner = self._input_rect.inflate(-12, -8) self.cursor_pos = max(0, min(self.cursor_pos, len(self.input_text))) cursor_x = self._font.size(self.input_text[: self.cursor_pos])[0] text_surface = self._font.render(self.input_text or "", True, (25, 30, 36)) # Scroll the text horizontally so the cursor stays visible, and clip # the text to the input field so long input cannot overflow the panel. offset = min(0, inner.width - cursor_x - 2) previous_clip = target_surface.get_clip() target_surface.set_clip(inner) text_y = inner.centery - text_surface.get_height() // 2 target_surface.blit(text_surface, (inner.x + offset, text_y)) if self.focus_index == 0 and pygame.time.get_ticks() % 1000 < 600: caret_x = inner.x + offset + cursor_x pygame.draw.line( target_surface, (25, 30, 36), (caret_x, inner.y + 2), (caret_x, inner.bottom - 2), ) target_surface.set_clip(previous_clip) def _draw_button(self, target_surface: pygame.Surface, button: DialogButton) -> None: focused = button.index == self.focus_index fill = (48, 87, 132) if focused else (63, 93, 132) border = (21, 35, 54) if focused else (35, 48, 66) pygame.draw.rect(target_surface, fill, button.rect, border_radius=5) pygame.draw.rect(target_surface, border, button.rect, 2 if focused else 1, border_radius=5) label = self._fit_text(button.label, button.rect.width - 16, self._button_font) text_surface = self._button_font.render(label, True, (255, 255, 255)) text_rect = text_surface.get_rect(center=button.rect.center) target_surface.blit(text_surface, text_rect) def _fit_text(self, text: str, max_width: int, font: pygame.font.Font) -> str: if font.size(text)[0] <= max_width: return text clipped = text while clipped and font.size(clipped + "...")[0] > max_width: clipped = clipped[:-1] return clipped + "..." if clipped else "..." @staticmethod def _wrap_text(text: str, max_width: int, font: pygame.font.Font) -> list[str]: lines: list[str] = [] for paragraph in str(text).splitlines() or [""]: current = "" for word in paragraph.split() or [""]: parts = Dialog._split_word(word, max_width, font) for part in parts: candidate = part if not current else f"{current} {part}" if font.size(candidate)[0] <= max_width: current = candidate else: if current: lines.append(current) current = part lines.append(current) return lines @staticmethod def _split_word(word: str, max_width: int, font: pygame.font.Font) -> list[str]: if font.size(word)[0] <= max_width: return [word] parts: list[str] = [] current = "" for char in word: if current and font.size(current + char)[0] > max_width: parts.append(current) current = char else: current += char if current: parts.append(current) return parts
[Doku] class DialogService: """Factory for modal dialogs on a world. All factory methods return immediately; the result is delivered to the ``callback`` (and stored in ``dialog.value`` after the dialog closed). Cancelling a dialog (ESC) always delivers ``None``. Pass ``pause=True`` to stop the world's logic (acting, timers, collisions) while the dialog is open. """ def __init__(self, world) -> None: self.world = world
[Doku] def msgbox( self, msg: str = "", title: str = "", button: str = "OK", callback: Callable[[bool | None], None] | None = None, **kwargs, ) -> Dialog: """Shows a message with a single confirmation button. The callback receives ``True`` when confirmed and ``None`` when the dialog was cancelled with ESC. """ return self._open( Dialog(self.world, msg, title, (str(button),), "msg", callback=callback, **kwargs) )
[Doku] def alert( self, msg: str = "", title: str = "", button: str = "OK", callback: Callable[[bool | None], None] | None = None, **kwargs, ) -> Dialog: """Alias for :meth:`msgbox`.""" return self.msgbox(msg, title, button, callback, **kwargs)
[Doku] def ynbox( self, msg: str = "", title: str = "", choices: Sequence[str] = ("Yes", "No"), callback: Callable[[bool | None], None] | None = None, **kwargs, ) -> Dialog: choices = tuple(choices) if len(choices) != 2: raise ValueError("ynbox choices must contain exactly two labels") return self._open(Dialog(self.world, msg, title, choices, "yn", callback=callback, **kwargs))
[Doku] def choicebox( self, msg: str = "", title: str = "", choices: Sequence[str] = (), callback: Callable[[str | None], None] | None = None, **kwargs, ) -> Dialog: if not choices: raise ValueError("choicebox choices must not be empty") return self._open(Dialog(self.world, msg, title, choices, "choice", callback=callback, **kwargs))
[Doku] def enterbox( self, msg: str = "", title: str = "", default: str = "", callback: Callable[[str | None], None] | None = None, **kwargs, ) -> Dialog: return self._open( Dialog(self.world, msg, title, (), "input", default=default, callback=callback, **kwargs) )
def _open(self, dialog: Dialog) -> Dialog: active = getattr(self.world, "_active_dialog", None) if active and active.is_open: active.close(None, notify=False) dialog._suppress_active_press = active._suppress_active_press self.world._active_dialog = dialog if self._is_left_mouse_pressed(): dialog._suppress_active_press = True dialog.on_opened() return dialog @staticmethod def _is_left_mouse_pressed() -> bool: import miniworlds.base.app as app_mod app = app_mod.App.get_running_app() event_manager = getattr(app, "event_manager", None) pressed_buttons = getattr(event_manager, "is_mouse_pressed", None) return bool(pressed_buttons) and "mouse_left" in pressed_buttons