""" 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 # For swipe gestures, use the starting position (mouse_down_pos) # For tap/long-press, use the ending position (mouse_up_pos) 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 - use starting position for location-based checks x, y = self.mouse_down_pos 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: # For swipe gestures, (x,y) is the start position # For tap/long-press, (x,y) is the tap position 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()