402 lines
13 KiB
Python
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()
|