From 2aae1a88ea2973834033c35a1f345acf9e512cc8 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Nov 2025 00:06:32 +0100 Subject: [PATCH] split out overlays --- dreader/application.py | 277 ++++++++------------------------- dreader/overlays/__init__.py | 21 +++ dreader/overlays/base.py | 273 ++++++++++++++++++++++++++++++++ dreader/overlays/navigation.py | 227 +++++++++++++++++++++++++++ dreader/overlays/settings.py | 217 ++++++++++++++++++++++++++ dreader/overlays/toc.py | 128 +++++++++++++++ 6 files changed, 927 insertions(+), 216 deletions(-) create mode 100644 dreader/overlays/__init__.py create mode 100644 dreader/overlays/base.py create mode 100644 dreader/overlays/navigation.py create mode 100644 dreader/overlays/settings.py create mode 100644 dreader/overlays/toc.py diff --git a/dreader/application.py b/dreader/application.py index 73c9611..4ceef14 100644 --- a/dreader/application.py +++ b/dreader/application.py @@ -54,6 +54,7 @@ from .state import OverlayState from .overlay import OverlayManager from .managers import DocumentManager, SettingsManager, HighlightCoordinator from .handlers import GestureRouter +from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay class EbookReader: @@ -129,9 +130,17 @@ class EbookReader: self.base_font_scale = 1.0 self.font_scale_step = 0.1 - # Overlay management + # 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) + self._overlay_subapps = { + OverlayState.NAVIGATION: NavigationOverlay(self), + OverlayState.SETTINGS: SettingsOverlay(self), + OverlayState.TOC: TOCOverlay(self), + } + self._active_overlay = None # Current active overlay sub-application def load_epub(self, epub_path: str) -> bool: """ @@ -250,13 +259,13 @@ class EbookReader: return None # If an overlay is open, return the cached composited overlay image - if self.is_overlay_open() and self.overlay_manager._cached_base_page: - # Return the last composited overlay image - # The overlay manager keeps this updated when settings change - return self.overlay_manager.composite_overlay( - self.overlay_manager._cached_base_page, - self.overlay_manager._cached_overlay_image - ) + if self.is_overlay_open() and self._active_overlay: + # Return the composited overlay from the sub-application + if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image: + return self._active_overlay.composite_overlay( + self._active_overlay._cached_base_page, + self._active_overlay._cached_overlay_image + ) try: page = self.manager.get_current_page() @@ -702,211 +711,26 @@ class EbookReader: def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse: - """Handle tap when overlay is open - select chapter, adjust settings, or close overlay""" - # For TOC overlay, use pyWebLayout link query to detect chapter clicks - if self.current_overlay_state == OverlayState.TOC: - # Query the overlay to see what was tapped - query_result = self.overlay_manager.query_overlay_pixel(x, y) + """ + Handle tap when overlay is open. - # If query failed (tap outside overlay), close it - if not query_result: - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - - # Check if tapped on a link (chapter) - if query_result.get("is_interactive") and query_result.get("link_target"): - link_target = query_result["link_target"] - - # Parse "chapter:N" format - if link_target.startswith("chapter:"): - try: - chapter_idx = int(link_target.split(":")[1]) - - # Get chapter title for response - chapters = self.get_chapters() - chapter_title = None - for title, idx in chapters: - if idx == chapter_idx: - chapter_title = title - break - - # Jump to selected chapter - self.jump_to_chapter(chapter_idx) - - # Close overlay - self.close_overlay() - - return GestureResponse(ActionType.CHAPTER_SELECTED, { - "chapter_index": chapter_idx, - "chapter_title": chapter_title or f"Chapter {chapter_idx}" - }) - except (ValueError, IndexError): - pass - - # Not a chapter link, close overlay + Delegates to the active overlay sub-application for handling. + If the response indicates the overlay should be closed, closes it. + """ + if not self._active_overlay: + # No active overlay, close legacy overlay if any self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - # For settings overlay, handle setting adjustments - elif self.current_overlay_state == OverlayState.SETTINGS: - # Query the overlay to see what was tapped - query_result = self.overlay_manager.query_overlay_pixel(x, y) + # Delegate to the active overlay sub-application + response = self._active_overlay.handle_tap(x, y) - # If query failed (tap outside overlay), close it - if not query_result: - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - - # Check if tapped on a settings control link - if query_result.get("is_interactive") and query_result.get("link_target"): - link_target = query_result["link_target"] - - # Parse "setting:action" format - if link_target.startswith("setting:"): - action = link_target.split(":", 1)[1] - - # Apply the setting change - if action == "font_increase": - self.increase_font_size() - elif action == "font_decrease": - self.decrease_font_size() - elif action == "line_spacing_increase": - new_spacing = self.page_style.line_spacing + 2 - self.set_line_spacing(new_spacing) - elif action == "line_spacing_decrease": - new_spacing = max(0, self.page_style.line_spacing - 2) - self.set_line_spacing(new_spacing) - elif action == "block_spacing_increase": - new_spacing = self.page_style.inter_block_spacing + 3 - self.set_inter_block_spacing(new_spacing) - elif action == "block_spacing_decrease": - new_spacing = max(0, self.page_style.inter_block_spacing - 3) - self.set_inter_block_spacing(new_spacing) - elif action == "word_spacing_increase": - new_spacing = self.page_style.word_spacing + 2 - self.set_word_spacing(new_spacing) - elif action == "word_spacing_decrease": - new_spacing = max(0, self.page_style.word_spacing - 2) - self.set_word_spacing(new_spacing) - - # Re-render the base page with new settings applied - # Must get directly from manager, not get_current_page() which returns overlay - page = self.manager.get_current_page() - updated_page = page.render() - - # Refresh the settings overlay with updated values and page - self.overlay_manager.refresh_settings_overlay( - updated_base_page=updated_page, - font_scale=self.base_font_scale, - line_spacing=self.page_style.line_spacing, - inter_block_spacing=self.page_style.inter_block_spacing, - word_spacing=self.page_style.word_spacing - ) - - return GestureResponse(ActionType.SETTING_CHANGED, { - "action": action, - "font_scale": self.base_font_scale, - "line_spacing": self.page_style.line_spacing, - "inter_block_spacing": self.page_style.inter_block_spacing, - "word_spacing": self.page_style.word_spacing - }) - - # Parse "action:command" format for other actions - elif link_target.startswith("action:"): - action = link_target.split(":", 1)[1] - - if action == "back_to_library": - # Close the overlay first - self.close_overlay() - # Return a special action for the application to handle - return GestureResponse(ActionType.BACK_TO_LIBRARY, {}) - - # Not a setting control, close overlay + # If the response indicates overlay should be closed, close it + if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED, + ActionType.BOOKMARK_SELECTED): self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - # For navigation overlay, handle tab switching, chapter/bookmark selection, and close - elif self.current_overlay_state == OverlayState.NAVIGATION: - # Query the overlay to see what was tapped - query_result = self.overlay_manager.query_overlay_pixel(x, y) - - # If query failed (tap outside overlay), close it - if not query_result: - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - - # Check if tapped on a link - if query_result.get("is_interactive") and query_result.get("link_target"): - link_target = query_result["link_target"] - - # Parse "tab:tabname" format for tab switching - if link_target.startswith("tab:"): - tab_name = link_target.split(":", 1)[1] - # Switch to the selected tab - self.switch_navigation_tab(tab_name) - return GestureResponse(ActionType.TAB_SWITCHED, { - "tab": tab_name - }) - - # Parse "chapter:N" format for chapter navigation - elif link_target.startswith("chapter:"): - try: - chapter_idx = int(link_target.split(":")[1]) - - # Get chapter title for response - chapters = self.get_chapters() - chapter_title = None - for title, idx in chapters: - if idx == chapter_idx: - chapter_title = title - break - - # Jump to selected chapter - self.jump_to_chapter(chapter_idx) - - # Close overlay - self.close_overlay() - - return GestureResponse(ActionType.CHAPTER_SELECTED, { - "chapter_index": chapter_idx, - "chapter_title": chapter_title or f"Chapter {chapter_idx}" - }) - except (ValueError, IndexError): - pass - - # Parse "bookmark:name" format for bookmark navigation - elif link_target.startswith("bookmark:"): - bookmark_name = link_target.split(":", 1)[1] - - # Load the bookmark position - page = self.load_position(bookmark_name) - if page: - # Close overlay - self.close_overlay() - - return GestureResponse(ActionType.BOOKMARK_SELECTED, { - "bookmark_name": bookmark_name - }) - else: - # Failed to load bookmark - return GestureResponse(ActionType.ERROR, { - "message": f"Failed to load bookmark: {bookmark_name}" - }) - - # Parse "action:close" format for close button - elif link_target.startswith("action:"): - action = link_target.split(":", 1)[1] - if action == "close": - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - - # Not an interactive element, close overlay - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - - # For other overlays, just close on any tap for now - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + return response # =================================================================== @@ -1139,8 +963,12 @@ class EbookReader: # Get chapters chapters = self.get_chapters() - # Open overlay and get composited image - result = self.overlay_manager.open_toc_overlay(chapters, base_page) + # Use the TOC sub-application + overlay_subapp = self._overlay_subapps[OverlayState.TOC] + result = overlay_subapp.open(base_page, chapters=chapters) + + # Update state + self._active_overlay = overlay_subapp self.current_overlay_state = OverlayState.TOC return result @@ -1166,14 +994,18 @@ class EbookReader: inter_block_spacing = self.page_style.inter_block_spacing word_spacing = self.page_style.word_spacing - # Open overlay and get composited image - result = self.overlay_manager.open_settings_overlay( + # Use the Settings sub-application + overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS] + result = overlay_subapp.open( base_page, font_scale=font_scale, line_spacing=line_spacing, inter_block_spacing=inter_block_spacing, word_spacing=word_spacing ) + + # Update state + self._active_overlay = overlay_subapp self.current_overlay_state = OverlayState.SETTINGS return result @@ -1237,13 +1069,17 @@ class EbookReader: for name in bookmark_names ] - # Open overlay and get composited image - result = self.overlay_manager.open_navigation_overlay( + # Use the Navigation sub-application + overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION] + result = overlay_subapp.open( + base_page, chapters=chapters, bookmarks=bookmarks, - base_page=base_page, active_tab=active_tab ) + + # Update state + self._active_overlay = overlay_subapp self.current_overlay_state = OverlayState.NAVIGATION return result @@ -1261,8 +1097,12 @@ class EbookReader: if self.current_overlay_state != OverlayState.NAVIGATION: return None - result = self.overlay_manager.switch_navigation_tab(new_tab) - return result if result else self.get_current_page() + # Delegate to the Navigation sub-application + if isinstance(self._active_overlay, NavigationOverlay): + result = self._active_overlay.switch_tab(new_tab) + return result if result else self.get_current_page() + + return None def close_overlay(self) -> Optional[Image.Image]: """ @@ -1274,7 +1114,12 @@ class EbookReader: if self.current_overlay_state == OverlayState.NONE: return None - result = self.overlay_manager.close_overlay() + # Close the active overlay sub-application + if self._active_overlay: + self._active_overlay.close() + self._active_overlay = None + + # Update state self.current_overlay_state = OverlayState.NONE # Return fresh current page diff --git a/dreader/overlays/__init__.py b/dreader/overlays/__init__.py new file mode 100644 index 0000000..030df3b --- /dev/null +++ b/dreader/overlays/__init__.py @@ -0,0 +1,21 @@ +""" +Overlay sub-applications for dreader. + +Each overlay is a self-contained sub-application that handles its own: +- HTML generation +- Rendering logic +- Gesture handling +- State management +""" + +from .base import OverlaySubApplication +from .navigation import NavigationOverlay +from .settings import SettingsOverlay +from .toc import TOCOverlay + +__all__ = [ + 'OverlaySubApplication', + 'NavigationOverlay', + 'SettingsOverlay', + 'TOCOverlay', +] diff --git a/dreader/overlays/base.py b/dreader/overlays/base.py new file mode 100644 index 0000000..c17c198 --- /dev/null +++ b/dreader/overlays/base.py @@ -0,0 +1,273 @@ +""" +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) diff --git a/dreader/overlays/navigation.py b/dreader/overlays/navigation.py new file mode 100644 index 0000000..cf576e4 --- /dev/null +++ b/dreader/overlays/navigation.py @@ -0,0 +1,227 @@ +""" +Navigation overlay sub-application. + +Provides tabbed interface for Contents (TOC) and Bookmarks. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Optional +from PIL import Image + +from .base import OverlaySubApplication +from ..gesture import GestureResponse, ActionType +from ..state import OverlayState +from ..html_generator import generate_navigation_overlay + +if TYPE_CHECKING: + from ..application import EbookReader + + +class NavigationOverlay(OverlaySubApplication): + """ + Unified navigation overlay with Contents and Bookmarks tabs. + + Features: + - Tab switching between Contents and Bookmarks + - Chapter navigation via clickable links + - Bookmark navigation + - Close button + """ + + def __init__(self, reader: 'EbookReader'): + """Initialize navigation overlay.""" + super().__init__(reader) + + # Tab state + self._active_tab: str = "contents" + self._cached_chapters: List[Tuple[str, int]] = [] + self._cached_bookmarks: List[Dict[str, Any]] = [] + + def get_overlay_type(self) -> OverlayState: + """Return NAVIGATION overlay type.""" + return OverlayState.NAVIGATION + + def open(self, base_page: Image.Image, **kwargs) -> Image.Image: + """ + Open the navigation overlay. + + Args: + base_page: Current reading page to show underneath + chapters: List of (chapter_title, chapter_index) tuples + bookmarks: List of bookmark dicts with 'name' and optional 'position' + active_tab: Which tab to show initially ("contents" or "bookmarks") + + Returns: + Composited image with navigation overlay + """ + chapters = kwargs.get('chapters', []) + bookmarks = kwargs.get('bookmarks', []) + active_tab = kwargs.get('active_tab', 'contents') + + # Store for later use (tab switching) + self._cached_chapters = chapters + self._cached_bookmarks = bookmarks + self._active_tab = active_tab + + # Calculate panel size (60% width, 70% height) + panel_size = self._calculate_panel_size(0.6, 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_size + ) + + # Render HTML to image + overlay_panel = self.render_html_to_image(html, panel_size) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_panel + + # Composite and return + return self.composite_overlay(base_page, overlay_panel) + + def handle_tap(self, x: int, y: int) -> GestureResponse: + """ + Handle tap within navigation overlay. + + Detects: + - Tab switching (tab:contents, tab:bookmarks) + - Chapter selection (chapter:N) + - Bookmark selection (bookmark:name) + - Close button (action:close) + - Tap outside overlay (closes) + + Args: + x, y: Screen coordinates of tap + + Returns: + GestureResponse with appropriate action + """ + # Query the overlay to see what was tapped + query_result = self.query_overlay_pixel(x, y) + + # If query failed (tap outside overlay), close it + if not query_result: + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Check if tapped on a link + if query_result.get("is_interactive") and query_result.get("link_target"): + link_target = query_result["link_target"] + + # Parse "tab:tabname" format for tab switching + if link_target.startswith("tab:"): + tab_name = link_target.split(":", 1)[1] + self._switch_tab(tab_name) + return GestureResponse(ActionType.TAB_SWITCHED, { + "tab": tab_name + }) + + # Parse "chapter:N" format for chapter navigation + elif link_target.startswith("chapter:"): + try: + chapter_idx = int(link_target.split(":")[1]) + + # Get chapter title for response + chapter_title = None + for title, idx in self._cached_chapters: + if idx == chapter_idx: + chapter_title = title + break + + # Jump to selected chapter + self.reader.jump_to_chapter(chapter_idx) + + return GestureResponse(ActionType.CHAPTER_SELECTED, { + "chapter_index": chapter_idx, + "chapter_title": chapter_title or f"Chapter {chapter_idx}" + }) + except (ValueError, IndexError): + pass + + # Parse "bookmark:name" format for bookmark navigation + elif link_target.startswith("bookmark:"): + bookmark_name = link_target.split(":", 1)[1] + + # Load the bookmark position + page = self.reader.load_position(bookmark_name) + if page: + return GestureResponse(ActionType.BOOKMARK_SELECTED, { + "bookmark_name": bookmark_name + }) + else: + # Failed to load bookmark + return GestureResponse(ActionType.ERROR, { + "message": f"Failed to load bookmark: {bookmark_name}" + }) + + # Parse "action:close" format for close button + elif link_target.startswith("action:"): + action = link_target.split(":", 1)[1] + if action == "close": + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Not an interactive element, close overlay + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + def switch_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 image with new tab active + """ + return self._switch_tab(new_tab) + + def _switch_tab(self, new_tab: str) -> Optional[Image.Image]: + """ + Internal tab switching implementation. + + Args: + new_tab: Tab to switch to + + Returns: + Updated composited image with new tab active + """ + if not self._cached_base_page: + return None + + self._active_tab = new_tab + + # Regenerate overlay with new active tab + panel_size = self._calculate_panel_size(0.6, 0.7) + + # Convert chapters to format expected by HTML generator + chapter_data = [ + {"index": idx, "title": title} + for title, idx in self._cached_chapters + ] + + # Generate navigation HTML with new active tab + html = generate_navigation_overlay( + chapters=chapter_data, + bookmarks=self._cached_bookmarks, + active_tab=new_tab, + page_size=panel_size + ) + + # Render HTML to image + overlay_panel = self.render_html_to_image(html, panel_size) + + # Update cache + self._cached_overlay_image = overlay_panel + + # Composite and return + return self.composite_overlay(self._cached_base_page, overlay_panel) diff --git a/dreader/overlays/settings.py b/dreader/overlays/settings.py new file mode 100644 index 0000000..efa6e88 --- /dev/null +++ b/dreader/overlays/settings.py @@ -0,0 +1,217 @@ +""" +Settings overlay sub-application. + +Provides interactive controls for adjusting reading settings with live preview. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional +from PIL import Image + +from .base import OverlaySubApplication +from ..gesture import GestureResponse, ActionType +from ..state import OverlayState +from ..html_generator import generate_settings_overlay + +if TYPE_CHECKING: + from ..application import EbookReader + + +class SettingsOverlay(OverlaySubApplication): + """ + Settings overlay with live preview. + + Features: + - Font size adjustment (increase/decrease) + - Line spacing adjustment + - Inter-block spacing adjustment + - Word spacing adjustment + - Live preview of changes on base page + - Back to library button + """ + + def get_overlay_type(self) -> OverlayState: + """Return SETTINGS overlay type.""" + return OverlayState.SETTINGS + + def open(self, base_page: Image.Image, **kwargs) -> Image.Image: + """ + Open the settings overlay. + + Args: + base_page: Current reading page to show underneath + font_scale: Current font scale + line_spacing: Current line spacing in pixels + inter_block_spacing: Current inter-block spacing in pixels + word_spacing: Current word spacing in pixels + + Returns: + Composited image with settings overlay + """ + font_scale = kwargs.get('font_scale', 1.0) + line_spacing = kwargs.get('line_spacing', 5) + inter_block_spacing = kwargs.get('inter_block_spacing', 15) + word_spacing = kwargs.get('word_spacing', 0) + + # Calculate panel size (60% width, 70% height) + panel_size = self._calculate_panel_size(0.6, 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_size + ) + + # Render HTML to image + overlay_panel = self.render_html_to_image(html, panel_size) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_panel + + # Composite and return + return self.composite_overlay(base_page, overlay_panel) + + def handle_tap(self, x: int, y: int) -> GestureResponse: + """ + Handle tap within settings overlay. + + Detects: + - Setting adjustment controls (setting:action) + - Back to library button (action:back_to_library) + - Tap outside overlay (closes) + + Args: + x, y: Screen coordinates of tap + + Returns: + GestureResponse with appropriate action + """ + # Query the overlay to see what was tapped + query_result = self.query_overlay_pixel(x, y) + + # If query failed (tap outside overlay), close it + if not query_result: + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Check if tapped on a settings control link + if query_result.get("is_interactive") and query_result.get("link_target"): + link_target = query_result["link_target"] + + # Parse "setting:action" format + if link_target.startswith("setting:"): + action = link_target.split(":", 1)[1] + return self._apply_setting_change(action) + + # Parse "action:command" format for other actions + elif link_target.startswith("action:"): + action = link_target.split(":", 1)[1] + + if action == "back_to_library": + return GestureResponse(ActionType.BACK_TO_LIBRARY, {}) + + # Not a setting control, close overlay + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + def refresh(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 + """ + # Calculate panel size (60% width, 70% height) + panel_size = self._calculate_panel_size(0.6, 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_size + ) + + # Render HTML to image + overlay_panel = self.render_html_to_image(html, panel_size) + + # 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 _apply_setting_change(self, action: str) -> GestureResponse: + """ + Apply a setting change and refresh the overlay. + + Args: + action: Setting action (e.g., "font_increase", "line_spacing_decrease") + + Returns: + GestureResponse with SETTING_CHANGED action + """ + # Apply the setting change via reader + if action == "font_increase": + self.reader.increase_font_size() + elif action == "font_decrease": + self.reader.decrease_font_size() + elif action == "line_spacing_increase": + new_spacing = self.reader.page_style.line_spacing + 2 + self.reader.set_line_spacing(new_spacing) + elif action == "line_spacing_decrease": + new_spacing = max(0, self.reader.page_style.line_spacing - 2) + self.reader.set_line_spacing(new_spacing) + elif action == "block_spacing_increase": + new_spacing = self.reader.page_style.inter_block_spacing + 3 + self.reader.set_inter_block_spacing(new_spacing) + elif action == "block_spacing_decrease": + new_spacing = max(0, self.reader.page_style.inter_block_spacing - 3) + self.reader.set_inter_block_spacing(new_spacing) + elif action == "word_spacing_increase": + new_spacing = self.reader.page_style.word_spacing + 2 + self.reader.set_word_spacing(new_spacing) + elif action == "word_spacing_decrease": + new_spacing = max(0, self.reader.page_style.word_spacing - 2) + self.reader.set_word_spacing(new_spacing) + + # Re-render the base page with new settings applied + # Must get directly from manager, not get_current_page() which returns overlay + page = self.reader.manager.get_current_page() + updated_page = page.render() + + # Refresh the settings overlay with updated values and page + self.refresh( + updated_base_page=updated_page, + font_scale=self.reader.base_font_scale, + line_spacing=self.reader.page_style.line_spacing, + inter_block_spacing=self.reader.page_style.inter_block_spacing, + word_spacing=self.reader.page_style.word_spacing + ) + + return GestureResponse(ActionType.SETTING_CHANGED, { + "action": action, + "font_scale": self.reader.base_font_scale, + "line_spacing": self.reader.page_style.line_spacing, + "inter_block_spacing": self.reader.page_style.inter_block_spacing, + "word_spacing": self.reader.page_style.word_spacing + }) diff --git a/dreader/overlays/toc.py b/dreader/overlays/toc.py new file mode 100644 index 0000000..410e0e9 --- /dev/null +++ b/dreader/overlays/toc.py @@ -0,0 +1,128 @@ +""" +Table of Contents overlay sub-application. + +Simple TOC overlay (deprecated in favor of NavigationOverlay). +Kept for backward compatibility. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, List, Tuple +from PIL import Image + +from .base import OverlaySubApplication +from ..gesture import GestureResponse, ActionType +from ..state import OverlayState +from ..html_generator import generate_toc_overlay + +if TYPE_CHECKING: + from ..application import EbookReader + + +class TOCOverlay(OverlaySubApplication): + """ + Simple Table of Contents overlay. + + NOTE: This is deprecated in favor of NavigationOverlay which provides + a unified interface for both TOC and bookmarks. Kept for backward compatibility. + + Features: + - List of chapters with clickable links + - Chapter navigation + """ + + def __init__(self, reader: 'EbookReader'): + """Initialize TOC overlay.""" + super().__init__(reader) + self._cached_chapters: List[Tuple[str, int]] = [] + + def get_overlay_type(self) -> OverlayState: + """Return TOC overlay type.""" + return OverlayState.TOC + + def open(self, base_page: Image.Image, **kwargs) -> Image.Image: + """ + Open the TOC overlay. + + Args: + base_page: Current reading page to show underneath + chapters: List of (chapter_title, chapter_index) tuples + + Returns: + Composited image with TOC overlay + """ + chapters = kwargs.get('chapters', []) + + # Store for later use + self._cached_chapters = chapters + + # Calculate panel size (60% width, 70% height) + panel_size = self._calculate_panel_size(0.6, 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_size) + + # Render HTML to image + overlay_panel = self.render_html_to_image(html, panel_size) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_panel + + # Composite and return + return self.composite_overlay(base_page, overlay_panel) + + def handle_tap(self, x: int, y: int) -> GestureResponse: + """ + Handle tap within TOC overlay. + + Detects: + - Chapter selection (chapter:N) + - Tap outside overlay (closes) + + Args: + x, y: Screen coordinates of tap + + Returns: + GestureResponse with appropriate action + """ + # Query the overlay to see what was tapped + query_result = self.query_overlay_pixel(x, y) + + # If query failed (tap outside overlay), close it + if not query_result: + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Check if tapped on a link (chapter) + if query_result.get("is_interactive") and query_result.get("link_target"): + link_target = query_result["link_target"] + + # Parse "chapter:N" format + if link_target.startswith("chapter:"): + try: + chapter_idx = int(link_target.split(":")[1]) + + # Get chapter title for response + chapter_title = None + for title, idx in self._cached_chapters: + if idx == chapter_idx: + chapter_title = title + break + + # Jump to selected chapter + self.reader.jump_to_chapter(chapter_idx) + + return GestureResponse(ActionType.CHAPTER_SELECTED, { + "chapter_index": chapter_idx, + "chapter_title": chapter_title or f"Chapter {chapter_idx}" + }) + except (ValueError, IndexError): + pass + + # Not a chapter link, close overlay + return GestureResponse(ActionType.OVERLAY_CLOSED, {})