split out overlays
Some checks failed
Python CI / test (push) Failing after 6m15s

This commit is contained in:
Duncan Tourolle 2025-11-09 00:06:32 +01:00
parent 18be4306bf
commit 2aae1a88ea
6 changed files with 927 additions and 216 deletions

View File

@ -54,6 +54,7 @@ from .state import OverlayState
from .overlay import OverlayManager from .overlay import OverlayManager
from .managers import DocumentManager, SettingsManager, HighlightCoordinator from .managers import DocumentManager, SettingsManager, HighlightCoordinator
from .handlers import GestureRouter from .handlers import GestureRouter
from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay
class EbookReader: class EbookReader:
@ -129,9 +130,17 @@ class EbookReader:
self.base_font_scale = 1.0 self.base_font_scale = 1.0
self.font_scale_step = 0.1 self.font_scale_step = 0.1
# Overlay management # Overlay management (legacy - kept for backward compatibility)
self.overlay_manager = OverlayManager(page_size=page_size) self.overlay_manager = OverlayManager(page_size=page_size)
self.current_overlay_state = OverlayState.NONE 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: def load_epub(self, epub_path: str) -> bool:
""" """
@ -250,13 +259,13 @@ class EbookReader:
return None return None
# If an overlay is open, return the cached composited overlay image # If an overlay is open, return the cached composited overlay image
if self.is_overlay_open() and self.overlay_manager._cached_base_page: if self.is_overlay_open() and self._active_overlay:
# Return the last composited overlay image # Return the composited overlay from the sub-application
# The overlay manager keeps this updated when settings change if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image:
return self.overlay_manager.composite_overlay( return self._active_overlay.composite_overlay(
self.overlay_manager._cached_base_page, self._active_overlay._cached_base_page,
self.overlay_manager._cached_overlay_image self._active_overlay._cached_overlay_image
) )
try: try:
page = self.manager.get_current_page() page = self.manager.get_current_page()
@ -702,211 +711,26 @@ class EbookReader:
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse: 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 Handle tap when overlay is open.
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)
# If query failed (tap outside overlay), close it Delegates to the active overlay sub-application for handling.
if not query_result: If the response indicates the overlay should be closed, closes it.
self.close_overlay() """
return GestureResponse(ActionType.OVERLAY_CLOSED, {}) if not self._active_overlay:
# No active overlay, close legacy overlay if any
# 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
self.close_overlay() self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {}) return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# For settings overlay, handle setting adjustments # Delegate to the active overlay sub-application
elif self.current_overlay_state == OverlayState.SETTINGS: response = self._active_overlay.handle_tap(x, y)
# 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 the response indicates overlay should be closed, close it
if not query_result: if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED,
self.close_overlay() ActionType.BOOKMARK_SELECTED):
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
self.close_overlay() self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# For navigation overlay, handle tab switching, chapter/bookmark selection, and close return response
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, {})
# =================================================================== # ===================================================================
@ -1139,8 +963,12 @@ class EbookReader:
# Get chapters # Get chapters
chapters = self.get_chapters() chapters = self.get_chapters()
# Open overlay and get composited image # Use the TOC sub-application
result = self.overlay_manager.open_toc_overlay(chapters, base_page) 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 self.current_overlay_state = OverlayState.TOC
return result return result
@ -1166,14 +994,18 @@ class EbookReader:
inter_block_spacing = self.page_style.inter_block_spacing inter_block_spacing = self.page_style.inter_block_spacing
word_spacing = self.page_style.word_spacing word_spacing = self.page_style.word_spacing
# Open overlay and get composited image # Use the Settings sub-application
result = self.overlay_manager.open_settings_overlay( overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS]
result = overlay_subapp.open(
base_page, base_page,
font_scale=font_scale, font_scale=font_scale,
line_spacing=line_spacing, line_spacing=line_spacing,
inter_block_spacing=inter_block_spacing, inter_block_spacing=inter_block_spacing,
word_spacing=word_spacing word_spacing=word_spacing
) )
# Update state
self._active_overlay = overlay_subapp
self.current_overlay_state = OverlayState.SETTINGS self.current_overlay_state = OverlayState.SETTINGS
return result return result
@ -1237,13 +1069,17 @@ class EbookReader:
for name in bookmark_names for name in bookmark_names
] ]
# Open overlay and get composited image # Use the Navigation sub-application
result = self.overlay_manager.open_navigation_overlay( overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION]
result = overlay_subapp.open(
base_page,
chapters=chapters, chapters=chapters,
bookmarks=bookmarks, bookmarks=bookmarks,
base_page=base_page,
active_tab=active_tab active_tab=active_tab
) )
# Update state
self._active_overlay = overlay_subapp
self.current_overlay_state = OverlayState.NAVIGATION self.current_overlay_state = OverlayState.NAVIGATION
return result return result
@ -1261,8 +1097,12 @@ class EbookReader:
if self.current_overlay_state != OverlayState.NAVIGATION: if self.current_overlay_state != OverlayState.NAVIGATION:
return None return None
result = self.overlay_manager.switch_navigation_tab(new_tab) # Delegate to the Navigation sub-application
return result if result else self.get_current_page() 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]: def close_overlay(self) -> Optional[Image.Image]:
""" """
@ -1274,7 +1114,12 @@ class EbookReader:
if self.current_overlay_state == OverlayState.NONE: if self.current_overlay_state == OverlayState.NONE:
return 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 self.current_overlay_state = OverlayState.NONE
# Return fresh current page # Return fresh current page

View File

@ -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',
]

273
dreader/overlays/base.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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
})

128
dreader/overlays/toc.py Normal file
View File

@ -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, {})