Duncan Tourolle 01e79dfa4b
All checks were successful
Python CI / test (3.12) (push) Successful in 22m19s
Python CI / test (3.13) (push) Successful in 8m23s
Test appplication for offdevice testing
2025-11-09 17:47:34 +01:00

402 lines
13 KiB
Python

"""
Pygame-based Display HAL for desktop testing.
This HAL implementation uses Pygame to provide a desktop window
for testing the e-reader application without physical hardware.
Features:
- Window display with PIL image rendering
- Mouse input converted to touch events
- Keyboard shortcuts for common actions
- Gesture detection (swipes via mouse drag)
Usage:
from dreader.hal_pygame import PygameDisplayHAL
from dreader.main import DReaderApplication, AppConfig
hal = PygameDisplayHAL(width=800, height=1200)
config = AppConfig(display_hal=hal, library_path="~/Books")
app = DReaderApplication(config)
await hal.run_event_loop(app)
"""
import asyncio
import logging
from typing import Optional
from PIL import Image
import numpy as np
from .hal import EventLoopHAL
from .gesture import TouchEvent, GestureType
logger = logging.getLogger(__name__)
# Pygame is optional - only needed for desktop testing
try:
import pygame
PYGAME_AVAILABLE = True
except ImportError:
PYGAME_AVAILABLE = False
logger.warning("Pygame not available. Install with: pip install pygame")
class PygameDisplayHAL(EventLoopHAL):
"""
Pygame-based display HAL for desktop testing.
This implementation provides a desktop window that simulates
an e-reader display with touch input.
"""
def __init__(
self,
width: int = 800,
height: int = 1200,
title: str = "DReader E-Book Reader",
fullscreen: bool = False
):
"""
Initialize Pygame display.
Args:
width: Window width in pixels
height: Window height in pixels
title: Window title
fullscreen: If True, open in fullscreen mode
"""
if not PYGAME_AVAILABLE:
raise RuntimeError("Pygame is required for PygameDisplayHAL. Install with: pip install pygame")
self.width = width
self.height = height
self.title = title
self.fullscreen = fullscreen
self.screen = None
self.running = False
# Touch/gesture tracking
self.mouse_down_pos: Optional[tuple[int, int]] = None
self.mouse_down_time: float = 0
self.drag_threshold = 20 # pixels (reduced from 30 for easier swiping)
self.long_press_duration = 0.5 # seconds
logger.info(f"PygameDisplayHAL initialized: {width}x{height}")
async def initialize(self):
"""Initialize Pygame and create window."""
logger.info("Initializing Pygame")
pygame.init()
# Set up display
flags = pygame.DOUBLEBUF
if self.fullscreen:
flags |= pygame.FULLSCREEN
self.screen = pygame.display.set_mode((self.width, self.height), flags)
pygame.display.set_caption(self.title)
# Set up font for messages
pygame.font.init()
logger.info("Pygame initialized successfully")
async def cleanup(self):
"""Clean up Pygame resources."""
logger.info("Cleaning up Pygame")
if pygame.get_init():
pygame.quit()
async def show_image(self, image: Image.Image):
"""
Display PIL image on Pygame window.
Args:
image: PIL Image to display
"""
if not self.screen:
logger.warning("Screen not initialized")
return
# Convert PIL image to pygame surface
# PIL uses RGB, pygame uses RGB
if image.mode != 'RGB':
image = image.convert('RGB')
# Resize if needed
if image.size != (self.width, self.height):
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
# Convert to numpy array, then to pygame surface
img_array = np.array(image)
surface = pygame.surfarray.make_surface(np.transpose(img_array, (1, 0, 2)))
# Blit to screen
self.screen.blit(surface, (0, 0))
pygame.display.flip()
# Small delay to prevent excessive CPU usage
await asyncio.sleep(0.001)
async def get_touch_event(self) -> Optional[TouchEvent]:
"""
Process pygame events and convert to TouchEvent.
Returns:
TouchEvent if available, None otherwise
"""
if not pygame.get_init():
return None
for event in pygame.event.get():
if event.type == pygame.QUIT:
logger.info("Quit event received")
self.running = False
return None
elif event.type == pygame.MOUSEBUTTONDOWN:
# Mouse down - start tracking for gesture
self.mouse_down_pos = event.pos
self.mouse_down_time = pygame.time.get_ticks() / 1000.0
logger.info(f"[MOUSE] Button DOWN at {event.pos}")
elif event.type == pygame.MOUSEMOTION:
# Show drag indicator while mouse is down
if self.mouse_down_pos and pygame.mouse.get_pressed()[0]:
current_pos = event.pos
dx = current_pos[0] - self.mouse_down_pos[0]
dy = current_pos[1] - self.mouse_down_pos[1]
distance = (dx**2 + dy**2) ** 0.5
# Log dragging in progress
if distance > 5: # Log any significant drag
logger.info(f"[DRAG] Moving: dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px")
# Only show if dragging beyond threshold
if distance > self.drag_threshold:
# Draw a line showing the swipe direction
if self.screen:
# This is just for visual feedback during drag
# The actual gesture detection happens on mouse up
pass
elif event.type == pygame.MOUSEBUTTONUP:
if self.mouse_down_pos is None:
logger.warning("[MOUSE] Button UP but no down position recorded")
continue
mouse_up_pos = event.pos
mouse_up_time = pygame.time.get_ticks() / 1000.0
# Calculate distance and time
dx = mouse_up_pos[0] - self.mouse_down_pos[0]
dy = mouse_up_pos[1] - self.mouse_down_pos[1]
distance = (dx**2 + dy**2) ** 0.5
duration = mouse_up_time - self.mouse_down_time
logger.info(f"[MOUSE] Button UP at {mouse_up_pos}")
logger.info(f"[GESTURE] dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px, duration={duration:.2f}s, threshold={self.drag_threshold}px")
# Detect gesture type
gesture = None
x, y = mouse_up_pos
if distance < self.drag_threshold:
# Tap or long press
if duration >= self.long_press_duration:
gesture = GestureType.LONG_PRESS
logger.info(f"[GESTURE] ✓ Detected: LONG_PRESS")
else:
gesture = GestureType.TAP
logger.info(f"[GESTURE] ✓ Detected: TAP")
else:
# Swipe
if abs(dx) > abs(dy):
# Horizontal swipe
if dx > 0:
gesture = GestureType.SWIPE_RIGHT
logger.info(f"[GESTURE] ✓ Detected: SWIPE_RIGHT (dx={dx:.0f})")
else:
gesture = GestureType.SWIPE_LEFT
logger.info(f"[GESTURE] ✓ Detected: SWIPE_LEFT (dx={dx:.0f})")
else:
# Vertical swipe
if dy > 0:
gesture = GestureType.SWIPE_DOWN
logger.info(f"[GESTURE] ✓ Detected: SWIPE_DOWN (dy={dy:.0f})")
else:
gesture = GestureType.SWIPE_UP
logger.info(f"[GESTURE] ✓ Detected: SWIPE_UP (dy={dy:.0f})")
# Reset tracking
self.mouse_down_pos = None
if gesture:
logger.info(f"[EVENT] Returning TouchEvent: {gesture.value} at ({x}, {y})")
return TouchEvent(gesture, x, y)
else:
logger.warning("[EVENT] No gesture detected (should not happen)")
elif event.type == pygame.KEYDOWN:
# Keyboard shortcuts
return await self._handle_keyboard(event)
return None
async def _handle_keyboard(self, event) -> Optional[TouchEvent]:
"""
Handle keyboard shortcuts.
Args:
event: Pygame keyboard event
Returns:
TouchEvent equivalent of keyboard action
"""
# Arrow keys for page navigation
if event.key == pygame.K_LEFT or event.key == pygame.K_PAGEUP:
# Previous page
return TouchEvent(GestureType.SWIPE_RIGHT, self.width // 2, self.height // 2)
elif event.key == pygame.K_RIGHT or event.key == pygame.K_PAGEDOWN or event.key == pygame.K_SPACE:
# Next page
return TouchEvent(GestureType.SWIPE_LEFT, self.width // 2, self.height // 2)
elif event.key == pygame.K_UP:
# Scroll up (if applicable)
return TouchEvent(GestureType.SWIPE_DOWN, self.width // 2, self.height // 2)
elif event.key == pygame.K_DOWN:
# Scroll down (if applicable)
return TouchEvent(GestureType.SWIPE_UP, self.width // 2, self.height // 2)
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
# Quit
logger.info("Quit via keyboard")
self.running = False
return None
elif event.key == pygame.K_EQUALS or event.key == pygame.K_PLUS:
# Zoom in (pinch out)
return TouchEvent(GestureType.PINCH_OUT, self.width // 2, self.height // 2)
elif event.key == pygame.K_MINUS:
# Zoom out (pinch in)
return TouchEvent(GestureType.PINCH_IN, self.width // 2, self.height // 2)
return None
async def set_brightness(self, level: int):
"""
Set display brightness (not supported in Pygame).
Args:
level: Brightness level (0-10)
Note: Brightness control is not available in Pygame.
This is a no-op for desktop testing.
"""
logger.debug(f"Brightness set to {level} (not supported in Pygame)")
async def run_event_loop(self, app):
"""
Run the Pygame event loop.
Args:
app: DReaderApplication instance
This method:
1. Initializes Pygame
2. Starts the application
3. Runs the event loop
4. Handles events and updates display
5. Shuts down gracefully
"""
logger.info("Starting Pygame event loop")
try:
# Initialize
await self.initialize()
await app.start()
self.running = True
# Show instructions
await self._show_instructions()
await asyncio.sleep(2)
# Main event loop
clock = pygame.time.Clock()
while self.running and app.is_running():
# Process events
touch_event = await self.get_touch_event()
if touch_event:
# Handle touch event
await app.handle_touch(touch_event)
# Cap frame rate
clock.tick(60) # 60 FPS max
await asyncio.sleep(0.001) # Yield to other async tasks
logger.info("Event loop ended")
except Exception as e:
logger.error(f"Error in event loop: {e}", exc_info=True)
raise
finally:
# Shutdown
logger.info("Shutting down application")
await app.shutdown()
await self.cleanup()
async def _show_instructions(self):
"""Show keyboard instructions overlay."""
if not self.screen:
return
# Create instruction text
font = pygame.font.Font(None, 24)
instructions = [
"DReader E-Book Reader",
"",
"Mouse Gestures:",
" Drag LEFT (horizontal) = Next Page",
" Drag RIGHT (horizontal) = Previous Page*",
" Drag UP (vertical) = Navigation/TOC Overlay",
" Drag DOWN (vertical) = Settings Overlay",
"",
"Keyboard Shortcuts:",
" Space / Right Arrow = Next Page",
" Left Arrow = Previous Page*",
" +/- = Font Size",
" Q/Escape = Quit",
"",
"*Previous page not working (pyWebLayout bug)",
"",
"Press any key to start..."
]
# Create semi-transparent overlay
overlay = pygame.Surface((self.width, self.height))
overlay.fill((255, 255, 255))
overlay.set_alpha(230)
# Render text
y = 100
for line in instructions:
if line:
text = font.render(line, True, (0, 0, 0))
else:
text = pygame.Surface((1, 20)) # Empty line
text_rect = text.get_rect(center=(self.width // 2, y))
overlay.blit(text, text_rect)
y += 30
# Display
self.screen.blit(overlay, (0, 0))
pygame.display.flip()