refinements
All checks were successful
Python CI / test (3.12) (push) Successful in 8m32s
Python CI / test (3.13) (push) Successful in 22m59s

This commit is contained in:
Duncan Tourolle 2025-11-09 21:17:57 +01:00
parent a552eb0951
commit 0f9e38eb7c
6 changed files with 41 additions and 610 deletions

View File

@ -24,7 +24,6 @@ from dreader.state import (
OverlayState OverlayState
) )
from dreader.library import LibraryManager from dreader.library import LibraryManager
from dreader.overlay import OverlayManager
from dreader.main import DReaderApplication, AppConfig from dreader.main import DReaderApplication, AppConfig
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
@ -56,9 +55,6 @@ __all__ = [
# Library # Library
"LibraryManager", "LibraryManager",
# Overlay
"OverlayManager",
# Main application # Main application
"DReaderApplication", "DReaderApplication",
"AppConfig", "AppConfig",

View File

@ -51,7 +51,6 @@ from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlig
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
from .state import OverlayState from .state import OverlayState
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 from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay
@ -130,17 +129,14 @@ 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 (legacy - kept for backward compatibility) # Overlay sub-applications
self.overlay_manager = OverlayManager(page_size=page_size)
self.current_overlay_state = OverlayState.NONE
# Overlay sub-applications (NEW architecture)
self._overlay_subapps = { self._overlay_subapps = {
OverlayState.NAVIGATION: NavigationOverlay(self), OverlayState.NAVIGATION: NavigationOverlay(self),
OverlayState.SETTINGS: SettingsOverlay(self), OverlayState.SETTINGS: SettingsOverlay(self),
OverlayState.TOC: TOCOverlay(self), OverlayState.TOC: TOCOverlay(self),
} }
self._active_overlay = None # Current active overlay sub-application self._active_overlay = None # Current active overlay sub-application
self.current_overlay_state = OverlayState.NONE
def load_epub(self, epub_path: str) -> bool: def load_epub(self, epub_path: str) -> bool:
""" """
@ -1014,29 +1010,12 @@ class EbookReader:
""" """
Open the bookmarks overlay. Open the bookmarks overlay.
This is a convenience method that opens the navigation overlay with the bookmarks tab active.
Returns: Returns:
Composited image with bookmarks overlay on top of current page, or None if no book loaded Composited image with bookmarks overlay on top of current page, or None if no book loaded
""" """
if not self.is_loaded(): return self.open_navigation_overlay(active_tab="bookmarks")
return None
# Get current page as base
base_page = self.get_current_page(include_highlights=False)
if not base_page:
return None
# Get bookmarks
bookmark_names = self.list_saved_positions()
bookmarks = [
{"name": name, "position": f"Saved position"}
for name in bookmark_names
]
# Open overlay and get composited image
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
self.current_overlay_state = OverlayState.BOOKMARKS
return result
def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]: def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]:
""" """

View File

@ -247,8 +247,12 @@ class GestureRouter:
return GestureResponse(ActionType.NONE, {}) return GestureResponse(ActionType.NONE, {})
def _handle_swipe_down(self, y: int) -> GestureResponse: def _handle_swipe_down(self, y: int) -> GestureResponse:
"""Handle swipe down gesture - opens Settings overlay""" """Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)"""
# Open settings overlay from anywhere on screen # Only open settings overlay if swipe starts from top 20% of screen
top_threshold = self.reader.page_size[1] * 0.2
if y > top_threshold:
return GestureResponse(ActionType.NONE, {})
overlay_image = self.reader.open_settings_overlay() overlay_image = self.reader.open_settings_overlay()
if overlay_image: if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, { return GestureResponse(ActionType.OVERLAY_OPENED, {

View File

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

View File

@ -29,12 +29,18 @@ class TestSettingsOverlay(unittest.TestCase):
self.reader = EbookReader(page_size=(800, 1200)) self.reader = EbookReader(page_size=(800, 1200))
# Load a test EPUB - use any available EPUB in test data # Load a test EPUB - use a larger EPUB for spacing tests
epub_dir = Path(__file__).parent / 'data' / 'library-epub' epub_dir = Path(__file__).parent / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub')) epubs = list(epub_dir.glob('*.epub'))
if not epubs: if not epubs:
self.skipTest("No test EPUB files available") self.skipTest("No test EPUB files available")
# Prefer larger EPUBs for better testing of spacing changes
# Skip minimal-test.epub as it has too little content
epubs = [e for e in epubs if 'minimal' not in e.name]
if not epubs:
epubs = list(epub_dir.glob('*.epub'))
test_epub = epubs[0] test_epub = epubs[0]
# Debug logging # Debug logging
@ -175,9 +181,12 @@ class TestSettingsOverlay(unittest.TestCase):
self.reader.open_settings_overlay() self.reader.open_settings_overlay()
initial_font_scale = self.reader.base_font_scale initial_font_scale = self.reader.base_font_scale
# Get overlay reader to query button positions # Get overlay reader to query button positions from the active overlay sub-application
overlay_manager = self.reader.overlay_manager overlay_subapp = self.reader._active_overlay
overlay_reader = overlay_manager._overlay_reader if not overlay_subapp:
self.skipTest("No active overlay sub-application")
overlay_reader = overlay_subapp._overlay_reader
if not overlay_reader or not overlay_reader.manager: if not overlay_reader or not overlay_reader.manager:
self.skipTest("Overlay reader not available for querying") self.skipTest("Overlay reader not available for querying")
@ -302,15 +311,17 @@ class TestSettingsOverlay(unittest.TestCase):
# Open overlay # Open overlay
self.reader.open_settings_overlay() self.reader.open_settings_overlay()
# Access refresh method through overlay manager # Access refresh method through active overlay sub-application
overlay_manager = self.reader.overlay_manager overlay_subapp = self.reader._active_overlay
if not overlay_subapp:
self.skipTest("No active overlay sub-application")
# Change a setting programmatically # Change a setting programmatically
self.reader.increase_font_size() self.reader.increase_font_size()
new_page = self.reader.get_current_page(include_highlights=False) new_page = self.reader.get_current_page(include_highlights=False)
# Refresh overlay # Refresh overlay
refreshed_image = overlay_manager.refresh_settings_overlay( refreshed_image = overlay_subapp.refresh(
updated_base_page=new_page, updated_base_page=new_page,
font_scale=self.reader.base_font_scale, font_scale=self.reader.base_font_scale,
line_spacing=self.reader.page_style.line_spacing, line_spacing=self.reader.page_style.line_spacing,

View File

@ -44,9 +44,14 @@ class TestTOCOverlay(unittest.TestCase):
self.reader.close() self.reader.close()
def test_overlay_manager_initialization(self): def test_overlay_manager_initialization(self):
"""Test that overlay manager is properly initialized""" """Test that overlay sub-applications are properly initialized"""
self.assertIsNotNone(self.reader.overlay_manager) # Check that overlay sub-applications exist
self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200)) self.assertIsNotNone(self.reader._overlay_subapps)
self.assertIn(OverlayState.TOC, self.reader._overlay_subapps)
self.assertIn(OverlayState.SETTINGS, self.reader._overlay_subapps)
self.assertIn(OverlayState.NAVIGATION, self.reader._overlay_subapps)
# Initially no overlay should be active
self.assertFalse(self.reader.is_overlay_open()) self.assertFalse(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
@ -298,13 +303,12 @@ class TestOverlayRendering(unittest.TestCase):
self.assertIsNotNone(html) self.assertIsNotNone(html)
self.assertIn("Table of Contents", html) self.assertIn("Table of Contents", html)
# Render HTML to image using overlay manager # Open the TOC overlay which internally renders HTML to image
overlay_manager = self.reader.overlay_manager overlay_image = self.reader.open_toc_overlay()
image = overlay_manager.render_html_to_image(html)
# Should produce valid image # Should produce valid image
self.assertIsNotNone(image) self.assertIsNotNone(overlay_image)
self.assertEqual(image.size, (800, 1200)) self.assertEqual(overlay_image.size, (800, 1200))
class TestTOCPagination(unittest.TestCase): class TestTOCPagination(unittest.TestCase):