""" Overlay management for dreader application. Handles rendering and compositing of overlay screens (TOC, Settings, Bookmarks) on top of the base reading page. """ from __future__ import annotations from typing import Optional, List, Dict, Any, Tuple from pathlib import Path from PIL import Image from .state import OverlayState from .html_generator import ( generate_toc_overlay, generate_settings_overlay, generate_bookmarks_overlay, generate_navigation_overlay ) class OverlayManager: """ Manages overlay rendering and interaction. Handles: - Generating overlay HTML - Rendering HTML to images using pyWebLayout - Compositing overlays on top of base pages - Tracking current overlay state """ def __init__(self, page_size: Tuple[int, int] = (800, 1200)): """ Initialize overlay manager. Args: page_size: Size of the page/overlay (width, height) """ self.page_size = page_size self.current_overlay = OverlayState.NONE self._cached_base_page: Optional[Image.Image] = None self._cached_overlay_image: Optional[Image.Image] = None self._overlay_reader = None # Will be EbookReader instance for rendering overlays self._overlay_panel_offset: Tuple[int, int] = (0, 0) # Panel position on screen def render_html_to_image(self, html: str, size: Optional[Tuple[int, int]] = None) -> Image.Image: """ Render HTML content to a PIL Image using pyWebLayout. This creates a temporary EbookReader instance to render the HTML, then extracts the rendered page as an image. Args: html: HTML string to render size: Optional (width, height) for rendering size. Defaults to self.page_size Returns: PIL Image of the rendered HTML """ # Import here to avoid circular dependency from .application import EbookReader render_size = size if size else self.page_size # Create a temporary reader for rendering this HTML temp_reader = EbookReader( page_size=render_size, margin=15, background_color=(255, 255, 255) ) # Load the HTML content success = temp_reader.load_html( html_string=html, title="Overlay", author="", document_id="temp_overlay" ) if not success: raise ValueError("Failed to load HTML for overlay rendering") # Get the rendered page image = temp_reader.get_current_page() # Clean up temp_reader.close() return image def composite_overlay(self, base_image: Image.Image, overlay_panel: Image.Image) -> Image.Image: """ Composite overlay panel on top of base image with darkened background. Creates a popup effect by: 1. Darkening the base image (multiply by 0.5) 2. Placing the overlay panel (60% size) centered on top Args: base_image: Base page image (reading page) overlay_panel: Rendered overlay panel (TOC, settings, etc.) Returns: Composited PIL Image with popup overlay effect """ from PIL import ImageDraw, ImageEnhance import numpy as np # Convert base image to RGB result = base_image.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) # 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 open_toc_overlay(self, chapters: List[Tuple[str, int]], base_page: Image.Image) -> Image.Image: """ Open the table of contents overlay. Args: chapters: List of (chapter_title, chapter_index) tuples base_page: Current reading page to show underneath Returns: Composited image with TOC overlay on top """ # Import here to avoid circular dependency from .application import EbookReader # Calculate panel size (60% of screen) panel_width = int(self.page_size[0] * 0.6) panel_height = int(self.page_size[1] * 0.7) # Convert chapters to format expected by HTML generator chapter_data = [ {"index": idx, "title": title} for title, idx in chapters ] # Generate TOC HTML with clickable links html = generate_toc_overlay(chapter_data, page_size=(panel_width, panel_height)) # Create reader for overlay and keep it alive for querying if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = EbookReader( page_size=(panel_width, panel_height), margin=15, background_color=(255, 255, 255) ) # Load the HTML content success = self._overlay_reader.load_html( html_string=html, title="Table of Contents", author="", document_id="toc_overlay" ) if not success: raise ValueError("Failed to load TOC overlay HTML") # Get the rendered page overlay_panel = self._overlay_reader.get_current_page() # Calculate and store panel position for coordinate translation panel_x = int((self.page_size[0] - panel_width) / 2) panel_y = int((self.page_size[1] - panel_height) / 2) self._overlay_panel_offset = (panel_x, panel_y) # Cache for later use self._cached_base_page = base_page.copy() self._cached_overlay_image = overlay_panel self.current_overlay = OverlayState.TOC # Composite and return return self.composite_overlay(base_page, overlay_panel) def open_settings_overlay( self, base_page: Image.Image, font_scale: float = 1.0, line_spacing: int = 5, inter_block_spacing: int = 15, word_spacing: int = 0 ) -> Image.Image: """ Open the settings overlay with current settings values. Args: base_page: Current reading page to show underneath font_scale: Current font scale line_spacing: Current line spacing inter_block_spacing: Current inter-block spacing word_spacing: Current word spacing Returns: Composited image with settings overlay on top """ # Import here to avoid circular dependency from .application import EbookReader # Calculate panel size (60% of screen) panel_width = int(self.page_size[0] * 0.6) panel_height = int(self.page_size[1] * 0.7) # Generate settings HTML with current values html = generate_settings_overlay( font_scale=font_scale, line_spacing=line_spacing, inter_block_spacing=inter_block_spacing, word_spacing=word_spacing, page_size=(panel_width, panel_height) ) # Create reader for overlay and keep it alive for querying if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = EbookReader( page_size=(panel_width, panel_height), margin=15, background_color=(255, 255, 255) ) # Load the HTML content success = self._overlay_reader.load_html( html_string=html, title="Settings", author="", document_id="settings_overlay" ) if not success: raise ValueError("Failed to load settings overlay HTML") # Get the rendered page overlay_panel = self._overlay_reader.get_current_page() # Calculate and store panel position for coordinate translation panel_x = int((self.page_size[0] - panel_width) / 2) panel_y = int((self.page_size[1] - panel_height) / 2) self._overlay_panel_offset = (panel_x, panel_y) # Cache for later use self._cached_base_page = base_page.copy() self._cached_overlay_image = overlay_panel self.current_overlay = OverlayState.SETTINGS # Composite and return return self.composite_overlay(base_page, overlay_panel) def refresh_settings_overlay( self, updated_base_page: Image.Image, font_scale: float, line_spacing: int, inter_block_spacing: int, word_spacing: int = 0 ) -> Image.Image: """ Refresh the settings overlay with updated values and background page. This is used for live preview when settings change - it updates both the background page (with new settings applied) and the overlay panel (with new values displayed). Args: updated_base_page: Updated reading page with new settings applied font_scale: Updated font scale line_spacing: Updated line spacing inter_block_spacing: Updated inter-block spacing word_spacing: Updated word spacing Returns: Composited image with updated settings overlay """ # Import here to avoid circular dependency from .application import EbookReader # Calculate panel size (60% of screen) panel_width = int(self.page_size[0] * 0.6) panel_height = int(self.page_size[1] * 0.7) # Generate updated settings HTML html = generate_settings_overlay( font_scale=font_scale, line_spacing=line_spacing, inter_block_spacing=inter_block_spacing, word_spacing=word_spacing, page_size=(panel_width, panel_height) ) # Recreate overlay reader with updated HTML if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = EbookReader( page_size=(panel_width, panel_height), margin=15, background_color=(255, 255, 255) ) success = self._overlay_reader.load_html( html_string=html, title="Settings", author="", document_id="settings_overlay" ) if not success: raise ValueError("Failed to load updated settings overlay HTML") # Get the updated rendered panel overlay_panel = self._overlay_reader.get_current_page() # Update caches self._cached_base_page = updated_base_page.copy() self._cached_overlay_image = overlay_panel # Composite and return return self.composite_overlay(updated_base_page, overlay_panel) def open_bookmarks_overlay(self, bookmarks: List[Dict[str, Any]], base_page: Image.Image) -> Image.Image: """ Open the bookmarks overlay. Args: bookmarks: List of bookmark dictionaries with 'name' and 'position' keys base_page: Current reading page to show underneath Returns: Composited image with bookmarks overlay on top """ # Generate bookmarks HTML html = generate_bookmarks_overlay(bookmarks) # Render HTML to image overlay_image = self.render_html_to_image(html) # Cache for later use self._cached_base_page = base_page.copy() self._cached_overlay_image = overlay_image self.current_overlay = OverlayState.BOOKMARKS # Composite and return return self.composite_overlay(base_page, overlay_image) def open_navigation_overlay( self, chapters: List[Tuple[str, int]], bookmarks: List[Dict], base_page: Image.Image, active_tab: str = "contents" ) -> Image.Image: """ Open the unified navigation overlay with Contents and Bookmarks tabs. This replaces the separate TOC and Bookmarks overlays with a single overlay that has tabs for switching between contents and bookmarks. Args: chapters: List of (chapter_title, chapter_index) tuples bookmarks: List of bookmark dictionaries with 'name' and optional 'position' base_page: Current reading page to show underneath active_tab: Which tab to show ("contents" or "bookmarks") Returns: Composited image with navigation overlay on top """ # Import here to avoid circular dependency from .application import EbookReader # Calculate panel size (60% of screen width, 70% height) panel_width = int(self.page_size[0] * 0.6) panel_height = int(self.page_size[1] * 0.7) # Convert chapters to format expected by HTML generator chapter_data = [ {"index": idx, "title": title} for title, idx in chapters ] # Generate navigation HTML with tabs html = generate_navigation_overlay( chapters=chapter_data, bookmarks=bookmarks, active_tab=active_tab, page_size=(panel_width, panel_height) ) # Create reader for overlay and keep it alive for querying if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = EbookReader( page_size=(panel_width, panel_height), margin=15, background_color=(255, 255, 255) ) # Load the HTML content success = self._overlay_reader.load_html( html_string=html, title="Navigation", author="", document_id="navigation_overlay" ) if not success: raise ValueError("Failed to load navigation overlay HTML") # Get the rendered page overlay_panel = self._overlay_reader.get_current_page() # Calculate and store panel position for coordinate translation panel_x = int((self.page_size[0] - panel_width) / 2) panel_y = int((self.page_size[1] - panel_height) / 2) self._overlay_panel_offset = (panel_x, panel_y) # Cache for later use self._cached_base_page = base_page.copy() self._cached_overlay_image = overlay_panel self.current_overlay = OverlayState.NAVIGATION # Store active tab for tab switching self._active_nav_tab = active_tab self._cached_chapters = chapters self._cached_bookmarks = bookmarks # Composite and return return self.composite_overlay(base_page, overlay_panel) def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]: """ Switch between tabs in the navigation overlay. Args: new_tab: Tab to switch to ("contents" or "bookmarks") Returns: Updated composited image with new tab active, or None if not in navigation overlay """ if self.current_overlay != OverlayState.NAVIGATION: return None # Re-open navigation overlay with new active tab if hasattr(self, '_cached_chapters') and hasattr(self, '_cached_bookmarks'): return self.open_navigation_overlay( chapters=self._cached_chapters, bookmarks=self._cached_bookmarks, base_page=self._cached_base_page, active_tab=new_tab ) return None def close_overlay(self) -> Optional[Image.Image]: """ Close the current overlay and return to base page. Returns: Base page image (without overlay), or None if no overlay was open """ if self.current_overlay == OverlayState.NONE: return None self.current_overlay = OverlayState.NONE base_page = self._cached_base_page # Clear caches self._cached_base_page = None self._cached_overlay_image = None self._overlay_panel_offset = (0, 0) # Close overlay reader if self._overlay_reader: self._overlay_reader.close() self._overlay_reader = None return base_page def is_overlay_open(self) -> bool: """Check if an overlay is currently open.""" return self.current_overlay != OverlayState.NONE def get_current_overlay_type(self) -> OverlayState: """Get the type of currently open overlay.""" return self.current_overlay def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]: """ Query a pixel in the current overlay to detect interactions. Uses pyWebLayout's query_point() to detect which element was tapped, including link targets and data attributes. Args: x, y: Pixel coordinates to query (in screen space) Returns: Dictionary with query result data (text, link_target, is_interactive), or None if no overlay open or query failed """ if not self.is_overlay_open() or 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 # 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 }