From 0f9e38eb7cf9cba69268919943a82ac411e3ee03 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Nov 2025 21:17:57 +0100 Subject: [PATCH] refinements --- dreader/__init__.py | 4 - dreader/application.py | 31 +- dreader/handlers/gestures.py | 8 +- dreader/overlay.py | 563 --------------------------------- tests/test_settings_overlay.py | 25 +- tests/test_toc_overlay.py | 20 +- 6 files changed, 41 insertions(+), 610 deletions(-) delete mode 100644 dreader/overlay.py diff --git a/dreader/__init__.py b/dreader/__init__.py index a2f7341..d32986f 100644 --- a/dreader/__init__.py +++ b/dreader/__init__.py @@ -24,7 +24,6 @@ from dreader.state import ( OverlayState ) from dreader.library import LibraryManager -from dreader.overlay import OverlayManager from dreader.main import DReaderApplication, AppConfig from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL @@ -56,9 +55,6 @@ __all__ = [ # Library "LibraryManager", - # Overlay - "OverlayManager", - # Main application "DReaderApplication", "AppConfig", diff --git a/dreader/application.py b/dreader/application.py index 1c1945b..09a4f3c 100644 --- a/dreader/application.py +++ b/dreader/application.py @@ -51,7 +51,6 @@ from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlig from .gesture import TouchEvent, GestureType, GestureResponse, ActionType from .state import OverlayState -from .overlay import OverlayManager from .managers import DocumentManager, SettingsManager, HighlightCoordinator from .handlers import GestureRouter from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay @@ -130,17 +129,14 @@ class EbookReader: self.base_font_scale = 1.0 self.font_scale_step = 0.1 - # Overlay management (legacy - kept for backward compatibility) - self.overlay_manager = OverlayManager(page_size=page_size) - self.current_overlay_state = OverlayState.NONE - - # Overlay sub-applications (NEW architecture) + # Overlay sub-applications self._overlay_subapps = { OverlayState.NAVIGATION: NavigationOverlay(self), OverlayState.SETTINGS: SettingsOverlay(self), OverlayState.TOC: TOCOverlay(self), } self._active_overlay = None # Current active overlay sub-application + self.current_overlay_state = OverlayState.NONE def load_epub(self, epub_path: str) -> bool: """ @@ -1014,29 +1010,12 @@ class EbookReader: """ Open the bookmarks overlay. + This is a convenience method that opens the navigation overlay with the bookmarks tab active. + Returns: Composited image with bookmarks overlay on top of current page, or None if no book loaded """ - if not self.is_loaded(): - return None - - # Get current page as base - base_page = self.get_current_page(include_highlights=False) - if not base_page: - return None - - # Get bookmarks - bookmark_names = self.list_saved_positions() - bookmarks = [ - {"name": name, "position": f"Saved position"} - for name in bookmark_names - ] - - # Open overlay and get composited image - result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page) - self.current_overlay_state = OverlayState.BOOKMARKS - - return result + return self.open_navigation_overlay(active_tab="bookmarks") def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]: """ diff --git a/dreader/handlers/gestures.py b/dreader/handlers/gestures.py index 4ac5ef8..23fa36c 100644 --- a/dreader/handlers/gestures.py +++ b/dreader/handlers/gestures.py @@ -247,8 +247,12 @@ class GestureRouter: return GestureResponse(ActionType.NONE, {}) def _handle_swipe_down(self, y: int) -> GestureResponse: - """Handle swipe down gesture - opens Settings overlay""" - # Open settings overlay from anywhere on screen + """Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)""" + # Only open settings overlay if swipe starts from top 20% of screen + top_threshold = self.reader.page_size[1] * 0.2 + if y > top_threshold: + return GestureResponse(ActionType.NONE, {}) + overlay_image = self.reader.open_settings_overlay() if overlay_image: return GestureResponse(ActionType.OVERLAY_OPENED, { diff --git a/dreader/overlay.py b/dreader/overlay.py deleted file mode 100644 index a20b69c..0000000 --- a/dreader/overlay.py +++ /dev/null @@ -1,563 +0,0 @@ -""" -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 - } diff --git a/tests/test_settings_overlay.py b/tests/test_settings_overlay.py index 9786f0f..5223df6 100644 --- a/tests/test_settings_overlay.py +++ b/tests/test_settings_overlay.py @@ -29,12 +29,18 @@ class TestSettingsOverlay(unittest.TestCase): self.reader = EbookReader(page_size=(800, 1200)) - # Load a test EPUB - use any available EPUB in test data + # Load a test EPUB - use a larger EPUB for spacing tests epub_dir = Path(__file__).parent / 'data' / 'library-epub' epubs = list(epub_dir.glob('*.epub')) if not epubs: self.skipTest("No test EPUB files available") + # Prefer larger EPUBs for better testing of spacing changes + # Skip minimal-test.epub as it has too little content + epubs = [e for e in epubs if 'minimal' not in e.name] + if not epubs: + epubs = list(epub_dir.glob('*.epub')) + test_epub = epubs[0] # Debug logging @@ -175,9 +181,12 @@ class TestSettingsOverlay(unittest.TestCase): self.reader.open_settings_overlay() initial_font_scale = self.reader.base_font_scale - # Get overlay reader to query button positions - overlay_manager = self.reader.overlay_manager - overlay_reader = overlay_manager._overlay_reader + # Get overlay reader to query button positions from the active overlay sub-application + overlay_subapp = self.reader._active_overlay + if not overlay_subapp: + self.skipTest("No active overlay sub-application") + + overlay_reader = overlay_subapp._overlay_reader if not overlay_reader or not overlay_reader.manager: self.skipTest("Overlay reader not available for querying") @@ -302,15 +311,17 @@ class TestSettingsOverlay(unittest.TestCase): # Open overlay self.reader.open_settings_overlay() - # Access refresh method through overlay manager - overlay_manager = self.reader.overlay_manager + # Access refresh method through active overlay sub-application + overlay_subapp = self.reader._active_overlay + if not overlay_subapp: + self.skipTest("No active overlay sub-application") # Change a setting programmatically self.reader.increase_font_size() new_page = self.reader.get_current_page(include_highlights=False) # Refresh overlay - refreshed_image = overlay_manager.refresh_settings_overlay( + refreshed_image = overlay_subapp.refresh( updated_base_page=new_page, font_scale=self.reader.base_font_scale, line_spacing=self.reader.page_style.line_spacing, diff --git a/tests/test_toc_overlay.py b/tests/test_toc_overlay.py index 0477140..3a58a41 100644 --- a/tests/test_toc_overlay.py +++ b/tests/test_toc_overlay.py @@ -44,9 +44,14 @@ class TestTOCOverlay(unittest.TestCase): self.reader.close() def test_overlay_manager_initialization(self): - """Test that overlay manager is properly initialized""" - self.assertIsNotNone(self.reader.overlay_manager) - self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200)) + """Test that overlay sub-applications are properly initialized""" + # Check that overlay sub-applications exist + self.assertIsNotNone(self.reader._overlay_subapps) + self.assertIn(OverlayState.TOC, self.reader._overlay_subapps) + self.assertIn(OverlayState.SETTINGS, self.reader._overlay_subapps) + self.assertIn(OverlayState.NAVIGATION, self.reader._overlay_subapps) + + # Initially no overlay should be active self.assertFalse(self.reader.is_overlay_open()) self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) @@ -298,13 +303,12 @@ class TestOverlayRendering(unittest.TestCase): self.assertIsNotNone(html) self.assertIn("Table of Contents", html) - # Render HTML to image using overlay manager - overlay_manager = self.reader.overlay_manager - image = overlay_manager.render_html_to_image(html) + # Open the TOC overlay which internally renders HTML to image + overlay_image = self.reader.open_toc_overlay() # Should produce valid image - self.assertIsNotNone(image) - self.assertEqual(image.size, (800, 1200)) + self.assertIsNotNone(overlay_image) + self.assertEqual(overlay_image.size, (800, 1200)) class TestTOCPagination(unittest.TestCase):