""" Base class for overlay sub-applications. This provides a common interface for all overlay types (TOC, Settings, Navigation, etc.) Each overlay is a self-contained sub-application that handles its own rendering and gestures. """ from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Optional, Dict, Any, Tuple from PIL import Image from ..gesture import GestureResponse, ActionType from ..state import OverlayState if TYPE_CHECKING: from ..application import EbookReader class OverlaySubApplication(ABC): """ Base class for overlay sub-applications. Each overlay type extends this class and implements: - open(): Generate HTML, render, and return composited image - handle_tap(): Process tap gestures within the overlay - close(): Clean up and return base page - get_overlay_type(): Return the OverlayState enum value The base class provides: - Common rendering infrastructure (HTML to image conversion) - Coordinate translation (screen to overlay panel) - Query pixel support (detecting interactive elements) - Compositing (darkened background + centered panel) """ def __init__(self, reader: 'EbookReader'): """ Initialize overlay sub-application. Args: reader: Reference to parent EbookReader instance """ self.reader = reader self.page_size = reader.page_size # Overlay rendering state self._overlay_reader: Optional['EbookReader'] = None self._cached_base_page: Optional[Image.Image] = None self._cached_overlay_image: Optional[Image.Image] = None self._overlay_panel_offset: Tuple[int, int] = (0, 0) self._panel_size: Tuple[int, int] = (0, 0) @abstractmethod def get_overlay_type(self) -> OverlayState: """ Get the overlay type identifier. Returns: OverlayState enum value for this overlay """ pass @abstractmethod def open(self, base_page: Image.Image, **kwargs) -> Image.Image: """ Open the overlay and return composited image. Args: base_page: Current reading page to show underneath **kwargs: Overlay-specific parameters Returns: Composited image with overlay on top of base page """ pass @abstractmethod def handle_tap(self, x: int, y: int) -> GestureResponse: """ Handle tap gesture within the overlay. Args: x, y: Screen coordinates of tap Returns: GestureResponse indicating what action to take """ pass def close(self) -> Optional[Image.Image]: """ Close the overlay and clean up resources. Returns: Base page image (without overlay), or None if not open """ base_page = self._cached_base_page # Clear caches self._cached_base_page = None self._cached_overlay_image = None self._overlay_panel_offset = (0, 0) self._panel_size = (0, 0) # Close overlay reader if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = None return base_page # =================================================================== # Common Infrastructure Methods # =================================================================== def render_html_to_image(self, html: str, panel_size: Tuple[int, int]) -> Image.Image: """ Render HTML to image using a temporary EbookReader. Args: html: HTML content to render panel_size: Size for the overlay panel (width, height) Returns: Rendered PIL Image of the HTML """ # Import here to avoid circular dependency from ..application import EbookReader # Create or reuse overlay reader if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = EbookReader( page_size=panel_size, margin=15, background_color=(255, 255, 255) ) # Load the HTML content success = self._overlay_reader.load_html( html_string=html, title=f"{self.get_overlay_type().name} Overlay", author="", document_id=f"{self.get_overlay_type().name.lower()}_overlay" ) if not success: raise ValueError(f"Failed to load {self.get_overlay_type().name} overlay HTML") # Get the rendered page return self._overlay_reader.get_current_page() def composite_overlay(self, base_page: Image.Image, overlay_panel: Image.Image) -> Image.Image: """ Composite overlay panel on top of base page with darkened background. Creates popup effect by: 1. Darkening the base image (70% brightness for e-ink visibility) 2. Placing the overlay panel centered on top with a border Args: base_page: Base reading page overlay_panel: Rendered overlay panel Returns: Composited PIL Image with popup effect """ from PIL import ImageDraw, ImageEnhance # Convert base image to RGB result = base_page.convert('RGB').copy() # Lighten the background slightly (70% brightness for e-ink visibility) enhancer = ImageEnhance.Brightness(result) result = enhancer.enhance(0.7) # Convert overlay panel to RGB if overlay_panel.mode != 'RGB': overlay_panel = overlay_panel.convert('RGB') # Calculate centered position for the panel panel_x = int((self.page_size[0] - overlay_panel.width) / 2) panel_y = int((self.page_size[1] - overlay_panel.height) / 2) # Store panel position and size for coordinate translation self._overlay_panel_offset = (panel_x, panel_y) self._panel_size = (overlay_panel.width, overlay_panel.height) # Add a thick black border around the panel for e-ink clarity draw = ImageDraw.Draw(result) border_width = 3 draw.rectangle( [panel_x - border_width, panel_y - border_width, panel_x + overlay_panel.width + border_width, panel_y + overlay_panel.height + border_width], outline=(0, 0, 0), width=border_width ) # Paste the panel onto the dimmed background result.paste(overlay_panel, (panel_x, panel_y)) return result def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]: """ Query a pixel in the overlay to detect interactive elements. Uses pyWebLayout's query_point() to detect tapped elements, including link targets and data attributes. Args: x, y: Screen coordinates to query Returns: Dictionary with query result (text, link_target, is_interactive), or None if query failed or coordinates outside overlay """ if not self._overlay_reader: return None # Translate screen coordinates to overlay panel coordinates panel_x, panel_y = self._overlay_panel_offset overlay_x = x - panel_x overlay_y = y - panel_y # Check if coordinates are within the overlay panel if overlay_x < 0 or overlay_y < 0: return None panel_width, panel_height = self._panel_size if overlay_x >= panel_width or overlay_y >= panel_height: return None # Get the current page from the overlay reader if not self._overlay_reader.manager: return None current_page = self._overlay_reader.manager.get_current_page() if not current_page: return None # Query the point result = current_page.query_point((overlay_x, overlay_y)) if not result: return None # Extract relevant data from QueryResult return { "text": result.text, "link_target": result.link_target, "is_interactive": result.is_interactive, "bounds": result.bounds, "object_type": result.object_type } def _calculate_panel_size(self, width_ratio: float = 0.6, height_ratio: float = 0.7) -> Tuple[int, int]: """ Calculate overlay panel size as a percentage of screen size. Args: width_ratio: Panel width as ratio of screen width (default 60%) height_ratio: Panel height as ratio of screen height (default 70%) Returns: Tuple of (panel_width, panel_height) in pixels """ panel_width = int(self.page_size[0] * width_ratio) panel_height = int(self.page_size[1] * height_ratio) return (panel_width, panel_height)