"""Base display abstraction for window management across backends.
This module provides the abstract interface that all display backends must implement.
The abstraction enables backend-agnostic experiment code while maintaining direct access
to backend-specific windows when needed.
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
[docs]
class BaseDisplay(ABC):
"""Abstract base class for display window management.
Provides unified interface for window creation, event handling, and drawing
across pygame, psychopy, and pyglet backends. Each backend implements this
interface with backend-specific window management.
The display owns the window throughout the experiment lifecycle. Users can:
- Access raw backend window via `window` property (Option A: direct access)
- Use backend-agnostic helper methods (Option B: abstraction layer)
"""
[docs]
def __init__(self, settings: object, shutdown_handler: object = None) -> None:
"""Initialize display and create window.
Args:
settings: Settings object containing BACKEND, FULLSCREEN, DISPLAY_INDEX, SCREEN_RES
shutdown_handler: Callable to invoke when Ctrl+C detected (for graceful shutdown)
"""
self.settings = settings
self.shutdown_handler = shutdown_handler
self._window = self._create_window(settings)
@property
def window(self) -> Any: # noqa: ANN401
"""Get raw backend-specific window object.
This provides direct access to the underlying window for backend-specific
operations (Option A).
Returns:
Backend window object:
- pygame: pygame.Surface
- psychopy: psychopy.visual.Window
- pyglet: pyglet.window.Window
"""
return self._window
@property
@abstractmethod
def backend_name(self) -> str:
"""Get backend identifier string.
Returns:
Backend name: "pygame", "psychopy", or "pyglet"
"""
@abstractmethod
def _create_window(self, settings: object) -> Any: # noqa: ANN401
"""Create backend-specific window.
Uses settings.screen_res, settings.fullscreen, and settings.display_index
to create appropriately configured window.
Args:
settings: Settings object with display configuration
Returns:
Backend-specific window object
"""
[docs]
@abstractmethod
def flip(self) -> None:
"""Update display to show drawn content.
Equivalent to:
- pygame: pygame.display.flip()
- psychopy: win.flip()
- pyglet: window.flip()
"""
[docs]
@abstractmethod
def close(self) -> None:
"""Close window and clean up display resources.
Called during tracker cleanup in end_experiment().
"""
[docs]
@abstractmethod
def get_events(self) -> list[dict[str, Any]]:
"""Poll for keyboard and mouse events.
Returns unified event dictionaries that abstract backend differences.
Returns:
List of event dicts with keys:
- 'type': 'keydown', 'keyup', 'quit', 'mousebuttondown', etc.
- 'key': key name string (for keyboard events)
- 'unicode': unicode character (for keyboard events)
- 'pos': (x, y) tuple (for mouse events)
- 'button': button number (for mouse events)
"""
[docs]
@abstractmethod
def fill(self, color: tuple[int, int, int]) -> None:
"""Fill entire window with specified RGB color.
Args:
color: RGB tuple (0-255, 0-255, 0-255)
Example:
display.fill((128, 128, 128)) # Gray background
"""
[docs]
@abstractmethod
def clear(self) -> None:
"""Clear window to black.
Convenience method equivalent to fill((0, 0, 0)).
"""
[docs]
@abstractmethod
def get_size(self) -> tuple[int, int]:
"""Get window dimensions.
Returns:
(width, height) in pixels
"""
[docs]
@abstractmethod
def draw_text(
self,
text: str,
pos: tuple[int, int] | None = None,
center: bool = False,
size: int = 24,
color: tuple[int, int, int] = (255, 255, 255),
) -> None:
"""Draw text on window.
Args:
text: Text string to display
pos: (x, y) position in pixels, None if center=True
center: If True, center text on screen (ignores pos)
size: Font size in points
color: RGB color tuple (0-255, 0-255, 0-255)
Example:
display.draw_text("Fixate +", center=True, size=48)
display.draw_text("Press SPACE", pos=(100, 100))
"""
[docs]
@abstractmethod
def draw_image(
self,
image_path: str | Path,
pos: tuple[int, int] | None = None,
center: bool = False,
scale: float | None = None,
) -> None:
"""Draw image on window.
Args:
image_path: Path to image file
pos: (x, y) position in pixels, None if center=True
center: If True, center image on screen (ignores pos)
scale: Scale factor (1.0 = original size, 2.0 = double size, etc.)
Example:
display.draw_image("stimulus.png", center=True)
display.draw_image("cue.png", pos=(100, 100), scale=0.5)
"""
[docs]
def wait_for_key(self, key: str | None = None, timeout: float | None = None) -> str | None:
"""Wait for keyboard input.
Helper method that polls events until key pressed or timeout.
Args:
key: Specific key to wait for (e.g., 'space', 'return'), or None for any key
timeout: Maximum time to wait in seconds, or None to wait indefinitely
Returns:
Key name that was pressed, or None if timeout
"""
start_time = time.time()
while True:
events = self.get_events()
for event in events:
if event.get("type") == "keydown":
pressed_key = event.get("key", "")
if key is None or pressed_key.lower() == key.lower():
return pressed_key
if timeout is not None and (time.time() - start_time) > timeout:
return None
time.sleep(0.001)
[docs]
def wait(self, duration: float) -> None:
"""Wait for specified duration while handling events.
Prevents event queue buildup during delays.
Args:
duration: Time to wait in seconds
"""
start_time = time.time()
while time.time() - start_time < duration:
self.get_events()
time.sleep(0.001)