This commit is contained in:
parent
18be4306bf
commit
2aae1a88ea
@ -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
|
||||||
|
|||||||
21
dreader/overlays/__init__.py
Normal file
21
dreader/overlays/__init__.py
Normal 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
273
dreader/overlays/base.py
Normal 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)
|
||||||
227
dreader/overlays/navigation.py
Normal file
227
dreader/overlays/navigation.py
Normal 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)
|
||||||
217
dreader/overlays/settings.py
Normal file
217
dreader/overlays/settings.py
Normal 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
128
dreader/overlays/toc.py
Normal 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, {})
|
||||||
Loading…
x
Reference in New Issue
Block a user