refactor applications to delegate responsibilites
Some checks failed
Python CI / test (push) Failing after 4m11s

This commit is contained in:
Duncan Tourolle 2025-11-08 19:46:49 +01:00
parent 4811367905
commit fe140ba91f
13 changed files with 1468 additions and 435 deletions

View File

@ -41,19 +41,19 @@ import os
from PIL import Image from PIL import Image
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Block, HeadingLevel from pyWebLayout.abstract.block import Block, HeadingLevel
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
from pyWebLayout.layout.ereader_layout import RenderingPosition from pyWebLayout.layout.ereader_layout import RenderingPosition
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.core.query import QueryResult, SelectionRange from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result from pyWebLayout.core.highlight import Highlight, HighlightColor
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 .overlay import OverlayManager
from .managers import DocumentManager, SettingsManager, HighlightCoordinator
from .handlers import GestureRouter
class EbookReader: class EbookReader:
@ -109,22 +109,25 @@ class EbookReader:
inter_block_spacing=inter_block_spacing inter_block_spacing=inter_block_spacing
) )
# State # Core managers (NEW: Refactored into separate modules)
self.doc_manager = DocumentManager()
self.settings_manager = SettingsManager()
self.highlight_coordinator: Optional[HighlightCoordinator] = None
self.gesture_router = GestureRouter(self)
# Layout manager (initialized after loading)
self.manager: Optional[EreaderLayoutManager] = None self.manager: Optional[EreaderLayoutManager] = None
# Legacy compatibility properties
self.blocks: Optional[List[Block]] = None self.blocks: Optional[List[Block]] = None
self.document_id: Optional[str] = None self.document_id: Optional[str] = None
self.book_title: Optional[str] = None self.book_title: Optional[str] = None
self.book_author: Optional[str] = None self.book_author: Optional[str] = None
self.highlight_manager: Optional[HighlightManager] = None self.highlight_manager = None # Will delegate to highlight_coordinator
# Font scale state # Font scale state (delegated to settings_manager but kept for compatibility)
self.base_font_scale = 1.0 self.base_font_scale = 1.0
self.font_scale_step = 0.1 # 10% change per step self.font_scale_step = 0.1
# Selection state (for text selection gestures)
self._selection_start: Optional[Tuple[int, int]] = None
self._selection_end: Optional[Tuple[int, int]] = None
self._selected_range: Optional[SelectionRange] = None
# Overlay management # Overlay management
self.overlay_manager = OverlayManager(page_size=page_size) self.overlay_manager = OverlayManager(page_size=page_size)
@ -133,58 +136,47 @@ class EbookReader:
def load_epub(self, epub_path: str) -> bool: def load_epub(self, epub_path: str) -> bool:
""" """
Load an EPUB file into the reader. Load an EPUB file into the reader.
Args: Args:
epub_path: Path to the EPUB file epub_path: Path to the EPUB file
Returns: Returns:
True if loaded successfully, False otherwise True if loaded successfully, False otherwise
""" """
try: # Use DocumentManager to load the EPUB
# Validate path success = self.doc_manager.load_epub(epub_path)
if not os.path.exists(epub_path):
raise FileNotFoundError(f"EPUB file not found: {epub_path}")
# Load the EPUB
book = read_epub(epub_path)
# Extract metadata
self.book_title = book.get_title() or "Unknown Title"
self.book_author = book.get_metadata('AUTHOR') or "Unknown Author"
# Create document ID from filename
self.document_id = Path(epub_path).stem
# Extract all blocks from chapters
self.blocks = []
for chapter in book.chapters:
if hasattr(chapter, '_blocks'):
self.blocks.extend(chapter._blocks)
if not self.blocks:
raise ValueError("No content blocks found in EPUB")
# Initialize the ereader manager
self.manager = EreaderLayoutManager(
blocks=self.blocks,
page_size=self.page_size,
document_id=self.document_id,
buffer_size=self.buffer_size,
page_style=self.page_style,
bookmarks_dir=self.bookmarks_dir
)
# Initialize highlight manager for this document if not success:
self.highlight_manager = HighlightManager(
document_id=self.document_id,
highlights_dir=self.highlights_dir
)
return True
except Exception as e:
print(f"Error loading EPUB: {e}")
return False return False
# Set compatibility properties
self.book_title = self.doc_manager.title
self.book_author = self.doc_manager.author
self.document_id = self.doc_manager.document_id
self.blocks = self.doc_manager.blocks
# Initialize the ereader manager
self.manager = EreaderLayoutManager(
blocks=self.blocks,
page_size=self.page_size,
document_id=self.document_id,
buffer_size=self.buffer_size,
page_style=self.page_style,
bookmarks_dir=self.bookmarks_dir
)
# Initialize managers that depend on layout manager
self.settings_manager.set_manager(self.manager)
# Initialize highlight coordinator for this document
self.highlight_coordinator = HighlightCoordinator(
document_id=self.document_id,
highlights_dir=self.highlights_dir
)
self.highlight_coordinator.set_layout_manager(self.manager)
self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility
return True
def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool: def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool:
""" """
@ -202,41 +194,41 @@ class EbookReader:
Returns: Returns:
True if loaded successfully, False otherwise True if loaded successfully, False otherwise
""" """
try: # Use DocumentManager to load HTML
# Parse HTML into blocks success = self.doc_manager.load_html(html_string, title, author, document_id)
blocks = parse_html_string(html_string)
if not blocks: if not success:
raise ValueError("No content blocks parsed from HTML")
# Set metadata
self.book_title = title
self.book_author = author
self.document_id = document_id
self.blocks = blocks
# Initialize the ereader manager
self.manager = EreaderLayoutManager(
blocks=self.blocks,
page_size=self.page_size,
document_id=self.document_id,
buffer_size=self.buffer_size,
page_style=self.page_style,
bookmarks_dir=self.bookmarks_dir
)
# Initialize highlight manager for this document
self.highlight_manager = HighlightManager(
document_id=self.document_id,
highlights_dir=self.highlights_dir
)
return True
except Exception as e:
print(f"Error loading HTML: {e}")
return False return False
# Set compatibility properties
self.book_title = self.doc_manager.title
self.book_author = self.doc_manager.author
self.document_id = self.doc_manager.document_id
self.blocks = self.doc_manager.blocks
# Initialize the ereader manager
self.manager = EreaderLayoutManager(
blocks=self.blocks,
page_size=self.page_size,
document_id=self.document_id,
buffer_size=self.buffer_size,
page_style=self.page_style,
bookmarks_dir=self.bookmarks_dir
)
# Initialize managers that depend on layout manager
self.settings_manager.set_manager(self.manager)
# Initialize highlight coordinator for this document
self.highlight_coordinator = HighlightCoordinator(
document_id=self.document_id,
highlights_dir=self.highlights_dir
)
self.highlight_coordinator.set_layout_manager(self.manager)
self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility
return True
def is_loaded(self) -> bool: def is_loaded(self) -> bool:
"""Check if a book is currently loaded.""" """Check if a book is currently loaded."""
return self.manager is not None return self.manager is not None
@ -466,52 +458,50 @@ class EbookReader:
def set_font_size(self, scale: float) -> Optional[Image.Image]: def set_font_size(self, scale: float) -> Optional[Image.Image]:
""" """
Set the font size scale and re-render current page. Set the font size scale and re-render current page.
Args: Args:
scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size) scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size)
Returns: Returns:
PIL Image of the re-rendered page with new font size PIL Image of the re-rendered page with new font size
""" """
if not self.manager: result = self.settings_manager.set_font_size(scale)
return None if result:
self.base_font_scale = self.settings_manager.font_scale # Sync compatibility property
try: return result
self.base_font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x
page = self.manager.set_font_scale(self.base_font_scale)
return page.render()
except Exception as e:
print(f"Error setting font size: {e}")
return None
def increase_font_size(self) -> Optional[Image.Image]: def increase_font_size(self) -> Optional[Image.Image]:
""" """
Increase font size by one step and re-render. Increase font size by one step and re-render.
Returns: Returns:
PIL Image of the re-rendered page PIL Image of the re-rendered page
""" """
new_scale = self.base_font_scale + self.font_scale_step result = self.settings_manager.increase_font_size()
return self.set_font_size(new_scale) if result:
self.base_font_scale = self.settings_manager.font_scale
return result
def decrease_font_size(self) -> Optional[Image.Image]: def decrease_font_size(self) -> Optional[Image.Image]:
""" """
Decrease font size by one step and re-render. Decrease font size by one step and re-render.
Returns: Returns:
PIL Image of the re-rendered page PIL Image of the re-rendered page
""" """
new_scale = self.base_font_scale - self.font_scale_step result = self.settings_manager.decrease_font_size()
return self.set_font_size(new_scale) if result:
self.base_font_scale = self.settings_manager.font_scale
return result
def get_font_size(self) -> float: def get_font_size(self) -> float:
""" """
Get the current font size scale. Get the current font size scale.
Returns: Returns:
Current font scale factor Current font scale factor
""" """
return self.base_font_scale return self.settings_manager.get_font_size()
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]: def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
""" """
@ -523,28 +513,8 @@ class EbookReader:
Returns: Returns:
PIL Image of the re-rendered page PIL Image of the re-rendered page
""" """
if not self.manager: return self.settings_manager.set_line_spacing(spacing)
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.line_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_line_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_line_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render()
except Exception as e:
print(f"Error setting line spacing: {e}")
return None
def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]: def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]:
""" """
Set inter-block spacing using pyWebLayout's native support. Set inter-block spacing using pyWebLayout's native support.
@ -555,27 +525,7 @@ class EbookReader:
Returns: Returns:
PIL Image of the re-rendered page PIL Image of the re-rendered page
""" """
if not self.manager: return self.settings_manager.set_inter_block_spacing(spacing)
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.inter_block_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_inter_block_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_inter_block_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render()
except Exception as e:
print(f"Error setting inter-block spacing: {e}")
return None
def set_word_spacing(self, spacing: int) -> Optional[Image.Image]: def set_word_spacing(self, spacing: int) -> Optional[Image.Image]:
""" """
@ -587,27 +537,7 @@ class EbookReader:
Returns: Returns:
PIL Image of the re-rendered page PIL Image of the re-rendered page
""" """
if not self.manager: return self.settings_manager.set_word_spacing(spacing)
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.word_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_word_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_word_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render()
except Exception as e:
print(f"Error setting word spacing: {e}")
return None
def get_position_info(self) -> Dict[str, Any]: def get_position_info(self) -> Dict[str, Any]:
""" """
@ -715,12 +645,7 @@ class EbookReader:
Returns: Returns:
Dictionary with all current settings Dictionary with all current settings
""" """
return { return self.settings_manager.get_current_settings()
'font_scale': self.base_font_scale,
'line_spacing': self.page_style.line_spacing if self.manager else 5,
'inter_block_spacing': self.page_style.inter_block_spacing if self.manager else 15,
'word_spacing': self.page_style.word_spacing if self.manager else 0
}
def apply_settings(self, settings: Dict[str, Any]) -> bool: def apply_settings(self, settings: Dict[str, Any]) -> bool:
""" """
@ -734,35 +659,11 @@ class EbookReader:
Returns: Returns:
True if settings applied successfully, False otherwise True if settings applied successfully, False otherwise
""" """
if not self.manager: success = self.settings_manager.apply_settings(settings)
return False if success:
# Sync compatibility property
try: self.base_font_scale = self.settings_manager.font_scale
# Apply font scale return success
font_scale = settings.get('font_scale', 1.0)
if font_scale != self.base_font_scale:
self.set_font_size(font_scale)
# Apply line spacing
line_spacing = settings.get('line_spacing', 5)
if line_spacing != self.page_style.line_spacing:
self.set_line_spacing(line_spacing)
# Apply inter-block spacing
inter_block_spacing = settings.get('inter_block_spacing', 15)
if inter_block_spacing != self.page_style.inter_block_spacing:
self.set_inter_block_spacing(inter_block_spacing)
# Apply word spacing
word_spacing = settings.get('word_spacing', 0)
if word_spacing != self.page_style.word_spacing:
self.set_word_spacing(word_spacing)
return True
except Exception as e:
print(f"Error applying settings: {e}")
return False
# ===== Gesture Handling ===== # ===== Gesture Handling =====
# All business logic for touch input is handled here # All business logic for touch input is handled here
@ -780,44 +681,8 @@ class EbookReader:
Returns: Returns:
GestureResponse with action and data for UI to process GestureResponse with action and data for UI to process
""" """
if not self.is_loaded(): # Delegate to gesture router
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"}) return self.gesture_router.handle_touch(event)
# Handle overlay-specific gestures first
if self.is_overlay_open():
if event.gesture == GestureType.TAP:
return self._handle_overlay_tap(event.x, event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
# Swipe down closes overlay
return self._handle_overlay_close()
# Dispatch based on gesture type for normal reading mode
if event.gesture == GestureType.TAP:
return self._handle_tap(event.x, event.y)
elif event.gesture == GestureType.LONG_PRESS:
return self._handle_long_press(event.x, event.y)
elif event.gesture == GestureType.SWIPE_LEFT:
return self._handle_page_forward()
elif event.gesture == GestureType.SWIPE_RIGHT:
return self._handle_page_back()
elif event.gesture == GestureType.SWIPE_UP:
# Swipe up from bottom opens TOC overlay
return self._handle_swipe_up(event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
# Swipe down from top opens settings overlay
return self._handle_swipe_down(event.y)
elif event.gesture == GestureType.PINCH_IN:
return self._handle_zoom_out()
elif event.gesture == GestureType.PINCH_OUT:
return self._handle_zoom_in()
elif event.gesture == GestureType.DRAG_START:
return self._handle_selection_start(event.x, event.y)
elif event.gesture == GestureType.DRAG_MOVE:
return self._handle_selection_move(event.x, event.y)
elif event.gesture == GestureType.DRAG_END:
return self._handle_selection_end(event.x, event.y)
return GestureResponse(ActionType.NONE, {})
def query_pixel(self, x: int, y: int) -> Optional[QueryResult]: def query_pixel(self, x: int, y: int) -> Optional[QueryResult]:
""" """
@ -835,184 +700,6 @@ class EbookReader:
page = self.manager.get_current_page() page = self.manager.get_current_page()
return page.query_point((x, y)) return page.query_point((x, y))
def _handle_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap gesture - activates links or selects words"""
page = self.manager.get_current_page()
result = page.query_point((x, y))
if not result or result.object_type == "empty":
return GestureResponse(ActionType.NONE, {})
# If it's a link, navigate
if result.is_interactive and result.link_target:
# Handle different link types
if result.link_target.endswith('.epub'):
# Open new book
success = self.load_epub(result.link_target)
if success:
return GestureResponse(ActionType.BOOK_LOADED, {
"title": self.book_title,
"author": self.book_author,
"path": result.link_target
})
else:
return GestureResponse(ActionType.ERROR, {
"message": f"Failed to load {result.link_target}"
})
else:
# Internal navigation (chapter)
self.jump_to_chapter(result.link_target)
return GestureResponse(ActionType.NAVIGATE, {
"target": result.link_target,
"chapter": self.get_current_chapter_info()
})
# Just a tap on text - select word
if result.text:
return GestureResponse(ActionType.WORD_SELECTED, {
"word": result.text,
"bounds": result.bounds
})
return GestureResponse(ActionType.NONE, {})
def _handle_long_press(self, x: int, y: int) -> GestureResponse:
"""Handle long-press - show definition or menu"""
page = self.manager.get_current_page()
result = page.query_point((x, y))
if result and result.text:
return GestureResponse(ActionType.DEFINE, {
"word": result.text,
"bounds": result.bounds
})
# Long-press on empty - show menu
return GestureResponse(ActionType.SHOW_MENU, {
"options": ["bookmark", "settings", "toc", "search"]
})
def _handle_page_forward(self) -> GestureResponse:
"""Handle swipe left - next page"""
img = self.next_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "forward",
"progress": self.get_reading_progress(),
"chapter": self.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_END, {})
def _handle_page_back(self) -> GestureResponse:
"""Handle swipe right - previous page"""
img = self.previous_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "back",
"progress": self.get_reading_progress(),
"chapter": self.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_START, {})
def _handle_zoom_in(self) -> GestureResponse:
"""Handle pinch out - increase font"""
self.increase_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "in",
"font_scale": self.base_font_scale
})
def _handle_zoom_out(self) -> GestureResponse:
"""Handle pinch in - decrease font"""
self.decrease_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "out",
"font_scale": self.base_font_scale
})
def _handle_selection_start(self, x: int, y: int) -> GestureResponse:
"""Start text selection"""
self._selection_start = (x, y)
self._selection_end = None
self._selected_range = None
return GestureResponse(ActionType.SELECTION_START, {
"start": (x, y)
})
def _handle_selection_move(self, x: int, y: int) -> GestureResponse:
"""Update text selection"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
# Query range
page = self.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_UPDATE, {
"start": self._selection_start,
"end": self._selection_end,
"text_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_selection_end(self, x: int, y: int) -> GestureResponse:
"""End text selection and return selected text"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
page = self.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_COMPLETE, {
"text": self._selected_range.text,
"word_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_swipe_up(self, y: int) -> GestureResponse:
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
# Check if swipe started from bottom 20% of screen
bottom_threshold = self.page_size[1] * 0.8
if y >= bottom_threshold:
# Open TOC overlay
overlay_image = self.open_toc_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "toc",
"chapters": self.get_chapters()
})
return GestureResponse(ActionType.NONE, {})
def _handle_swipe_down(self, y: int) -> GestureResponse:
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
# Check if swipe started from top 20% of screen
top_threshold = self.page_size[1] * 0.2
if y <= top_threshold:
# Open settings overlay
overlay_image = self.open_settings_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "settings",
"font_scale": self.base_font_scale,
"line_spacing": self.page_style.line_spacing,
"inter_block_spacing": self.page_style.inter_block_spacing
})
return GestureResponse(ActionType.NONE, {})
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""" """Handle tap when overlay is open - select chapter, adjust settings, or close overlay"""
@ -1142,10 +829,6 @@ class EbookReader:
self.close_overlay() self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {}) return GestureResponse(ActionType.OVERLAY_CLOSED, {})
def _handle_overlay_close(self) -> GestureResponse:
"""Handle overlay close gesture (swipe down)"""
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# =================================================================== # ===================================================================
# Highlighting API # Highlighting API

View File

@ -0,0 +1,10 @@
"""
Handlers module for dreader application.
This module contains interaction handlers:
- GestureRouter: Routes touch events to appropriate handlers
"""
from .gestures import GestureRouter
__all__ = ['GestureRouter']

View File

@ -0,0 +1,282 @@
"""
Gesture routing and handling.
This module handles all touch event routing and gesture logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Tuple
from ..gesture import TouchEvent, GestureType, GestureResponse, ActionType
if TYPE_CHECKING:
from ..application import EbookReader
from pyWebLayout.core.query import SelectionRange
class GestureRouter:
"""
Routes and handles all gestures.
This class centralizes all gesture handling logic, making it easier
to test and maintain gesture interactions.
"""
def __init__(self, reader: 'EbookReader'):
"""
Initialize the gesture router.
Args:
reader: EbookReader instance to route gestures for
"""
self.reader = reader
# Selection state (for text selection gestures)
self._selection_start: Optional[Tuple[int, int]] = None
self._selection_end: Optional[Tuple[int, int]] = None
self._selected_range: Optional['SelectionRange'] = None
def handle_touch(self, event: TouchEvent) -> GestureResponse:
"""
Handle a touch event from HAL.
This is the main entry point for all touch interactions.
Args:
event: TouchEvent from HAL with gesture type and coordinates
Returns:
GestureResponse with action and data for UI to process
"""
if not self.reader.is_loaded():
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
# Handle overlay-specific gestures first
if self.reader.is_overlay_open():
if event.gesture == GestureType.TAP:
return self._handle_overlay_tap(event.x, event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
return self._handle_overlay_close()
# Dispatch based on gesture type for normal reading mode
if event.gesture == GestureType.TAP:
return self._handle_tap(event.x, event.y)
elif event.gesture == GestureType.LONG_PRESS:
return self._handle_long_press(event.x, event.y)
elif event.gesture == GestureType.SWIPE_LEFT:
return self._handle_page_forward()
elif event.gesture == GestureType.SWIPE_RIGHT:
return self._handle_page_back()
elif event.gesture == GestureType.SWIPE_UP:
return self._handle_swipe_up(event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
return self._handle_swipe_down(event.y)
elif event.gesture == GestureType.PINCH_IN:
return self._handle_zoom_out()
elif event.gesture == GestureType.PINCH_OUT:
return self._handle_zoom_in()
elif event.gesture == GestureType.DRAG_START:
return self._handle_selection_start(event.x, event.y)
elif event.gesture == GestureType.DRAG_MOVE:
return self._handle_selection_move(event.x, event.y)
elif event.gesture == GestureType.DRAG_END:
return self._handle_selection_end(event.x, event.y)
return GestureResponse(ActionType.NONE, {})
# ===================================================================
# Reading Mode Gesture Handlers
# ===================================================================
def _handle_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap gesture - activates links or selects words"""
page = self.reader.manager.get_current_page()
result = page.query_point((x, y))
if not result or result.object_type == "empty":
return GestureResponse(ActionType.NONE, {})
# If it's a link, navigate
if result.is_interactive and result.link_target:
# Handle different link types
if result.link_target.endswith('.epub'):
# Open new book
success = self.reader.load_epub(result.link_target)
if success:
return GestureResponse(ActionType.BOOK_LOADED, {
"title": self.reader.book_title,
"author": self.reader.book_author,
"path": result.link_target
})
else:
return GestureResponse(ActionType.ERROR, {
"message": f"Failed to load {result.link_target}"
})
else:
# Internal navigation (chapter)
self.reader.jump_to_chapter(result.link_target)
return GestureResponse(ActionType.NAVIGATE, {
"target": result.link_target,
"chapter": self.reader.get_current_chapter_info()
})
# Just a tap on text - select word
if result.text:
return GestureResponse(ActionType.WORD_SELECTED, {
"word": result.text,
"bounds": result.bounds
})
return GestureResponse(ActionType.NONE, {})
def _handle_long_press(self, x: int, y: int) -> GestureResponse:
"""Handle long-press - show definition or menu"""
page = self.reader.manager.get_current_page()
result = page.query_point((x, y))
if result and result.text:
return GestureResponse(ActionType.DEFINE, {
"word": result.text,
"bounds": result.bounds
})
# Long-press on empty - show menu
return GestureResponse(ActionType.SHOW_MENU, {
"options": ["bookmark", "settings", "toc", "search"]
})
def _handle_page_forward(self) -> GestureResponse:
"""Handle swipe left - next page"""
img = self.reader.next_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "forward",
"progress": self.reader.get_reading_progress(),
"chapter": self.reader.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_END, {})
def _handle_page_back(self) -> GestureResponse:
"""Handle swipe right - previous page"""
img = self.reader.previous_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "back",
"progress": self.reader.get_reading_progress(),
"chapter": self.reader.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_START, {})
def _handle_zoom_in(self) -> GestureResponse:
"""Handle pinch out - increase font"""
self.reader.increase_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "in",
"font_scale": self.reader.base_font_scale
})
def _handle_zoom_out(self) -> GestureResponse:
"""Handle pinch in - decrease font"""
self.reader.decrease_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "out",
"font_scale": self.reader.base_font_scale
})
def _handle_selection_start(self, x: int, y: int) -> GestureResponse:
"""Start text selection"""
self._selection_start = (x, y)
self._selection_end = None
self._selected_range = None
return GestureResponse(ActionType.SELECTION_START, {
"start": (x, y)
})
def _handle_selection_move(self, x: int, y: int) -> GestureResponse:
"""Update text selection"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
# Query range
page = self.reader.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_UPDATE, {
"start": self._selection_start,
"end": self._selection_end,
"text_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_selection_end(self, x: int, y: int) -> GestureResponse:
"""End text selection and return selected text"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
page = self.reader.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_COMPLETE, {
"text": self._selected_range.text,
"word_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_swipe_up(self, y: int) -> GestureResponse:
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
# Check if swipe started from bottom 20% of screen
bottom_threshold = self.reader.page_size[1] * 0.8
if y >= bottom_threshold:
# Open TOC overlay
overlay_image = self.reader.open_toc_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "toc",
"chapters": self.reader.get_chapters()
})
return GestureResponse(ActionType.NONE, {})
def _handle_swipe_down(self, y: int) -> GestureResponse:
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
# Check if swipe started from top 20% of screen
top_threshold = self.reader.page_size[1] * 0.2
if y <= top_threshold:
# Open settings overlay
overlay_image = self.reader.open_settings_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "settings",
"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
})
return GestureResponse(ActionType.NONE, {})
# ===================================================================
# Overlay Mode Gesture Handlers
# ===================================================================
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap when overlay is open - delegates to EbookReader overlay handlers"""
# This remains in EbookReader because it's tightly coupled with overlay state
return self.reader._handle_overlay_tap(x, y)
def _handle_overlay_close(self) -> GestureResponse:
"""Handle overlay close gesture (swipe down)"""
self.reader.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})

View File

@ -0,0 +1,14 @@
"""
Managers module for dreader application.
This module contains business logic managers that handle specific responsibilities:
- DocumentManager: Document loading and metadata
- SettingsManager: Font size, spacing, and rendering settings
- HighlightCoordinator: Highlight operations coordination
"""
from .document import DocumentManager
from .settings import SettingsManager
from .highlight_coordinator import HighlightCoordinator
__all__ = ['DocumentManager', 'SettingsManager', 'HighlightCoordinator']

View File

@ -0,0 +1,137 @@
"""
Document loading and metadata management.
This module handles EPUB and HTML loading, extracting blocks and metadata.
"""
from __future__ import annotations
from typing import List, Tuple, Dict, Any, Optional
from pathlib import Path
import os
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Block
class DocumentManager:
"""
Handles document loading and metadata extraction.
Responsibilities:
- Load EPUB files
- Load HTML content
- Extract document metadata (title, author, etc.)
- Extract content blocks for rendering
"""
def __init__(self):
"""Initialize the document manager."""
self.document_id: Optional[str] = None
self.title: Optional[str] = None
self.author: Optional[str] = None
self.blocks: Optional[List[Block]] = None
def load_epub(self, epub_path: str) -> bool:
"""
Load an EPUB file and extract content.
Args:
epub_path: Path to the EPUB file
Returns:
True if loaded successfully, False otherwise
"""
try:
# Validate path
if not os.path.exists(epub_path):
raise FileNotFoundError(f"EPUB file not found: {epub_path}")
# Load the EPUB
book = read_epub(epub_path)
# Extract metadata
self.title = book.get_title() or "Unknown Title"
self.author = book.get_metadata('AUTHOR') or "Unknown Author"
# Create document ID from filename
self.document_id = Path(epub_path).stem
# Extract all blocks from chapters
self.blocks = []
for chapter in book.chapters:
if hasattr(chapter, '_blocks'):
self.blocks.extend(chapter._blocks)
if not self.blocks:
raise ValueError("No content blocks found in EPUB")
return True
except Exception as e:
print(f"Error loading EPUB: {e}")
return False
def load_html(self, html_string: str, title: str = "HTML Document",
author: str = "Unknown", document_id: str = "html_doc") -> bool:
"""
Load HTML content directly.
This is useful for rendering library screens, menus, or other HTML-based UI elements.
Args:
html_string: HTML content to render
title: Document title (for metadata)
author: Document author (for metadata)
document_id: Unique identifier for this HTML document
Returns:
True if loaded successfully, False otherwise
"""
try:
# Parse HTML into blocks
blocks = parse_html_string(html_string)
if not blocks:
raise ValueError("No content blocks parsed from HTML")
# Set metadata
self.title = title
self.author = author
self.document_id = document_id
self.blocks = blocks
return True
except Exception as e:
print(f"Error loading HTML: {e}")
return False
def is_loaded(self) -> bool:
"""Check if a document is currently loaded."""
return self.blocks is not None and len(self.blocks) > 0
def get_metadata(self) -> Dict[str, Any]:
"""
Get document metadata.
Returns:
Dictionary with metadata (title, author, document_id, total_blocks)
"""
return {
'title': self.title,
'author': self.author,
'document_id': self.document_id,
'total_blocks': len(self.blocks) if self.blocks else 0
}
def get_blocks(self) -> Optional[List[Block]]:
"""Get the list of content blocks."""
return self.blocks
def clear(self):
"""Clear the currently loaded document."""
self.document_id = None
self.title = None
self.author = None
self.blocks = None

View File

@ -0,0 +1,211 @@
"""
Highlight operations coordination.
This module coordinates highlight operations with the highlight manager.
"""
from __future__ import annotations
from typing import List, Tuple, Optional, TYPE_CHECKING
from PIL import Image
import numpy as np
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
if TYPE_CHECKING:
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
class HighlightCoordinator:
"""
Coordinates highlight operations.
This class provides a simplified interface for highlighting operations,
coordinating between the layout manager and highlight manager.
"""
def __init__(self, document_id: str, highlights_dir: str):
"""
Initialize the highlight coordinator.
Args:
document_id: Unique document identifier
highlights_dir: Directory to store highlights
"""
self.highlight_manager = HighlightManager(
document_id=document_id,
highlights_dir=highlights_dir
)
self.layout_manager: Optional['EreaderLayoutManager'] = None
def set_layout_manager(self, manager: 'EreaderLayoutManager'):
"""Set the layout manager."""
self.layout_manager = manager
def highlight_word(self, x: int, y: int,
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a word at the given pixel location.
Args:
x: X coordinate
y: Y coordinate
color: RGBA color tuple (defaults to yellow)
note: Optional annotation for this highlight
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
# Query the pixel to find the word
page = self.layout_manager.get_current_page()
result = page.query_point((x, y))
if not result or not result.text:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from query result
highlight = create_highlight_from_query_result(
result,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting word: {e}")
return None
def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int],
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a range of words between two points.
Args:
start: Starting (x, y) coordinates
end: Ending (x, y) coordinates
color: RGBA color tuple (defaults to yellow)
note: Optional annotation
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
page = self.layout_manager.get_current_page()
selection_range = page.query_range(start, end)
if not selection_range.results:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from selection range
highlight = create_highlight_from_query_result(
selection_range,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting selection: {e}")
return None
def remove_highlight(self, highlight_id: str) -> bool:
"""Remove a highlight by ID."""
return self.highlight_manager.remove_highlight(highlight_id)
def list_highlights(self) -> List[Highlight]:
"""Get all highlights for the current document."""
return self.highlight_manager.list_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""Get highlights that appear on a specific page."""
return self.highlight_manager.get_highlights_for_page(page_bounds)
def clear_all(self) -> None:
"""Remove all highlights from the current document."""
self.highlight_manager.clear_all()
def render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image:
"""
Render highlight overlays on an image using multiply blend mode.
Args:
image: Base PIL Image to draw on
highlights: List of Highlight objects to render
Returns:
New PIL Image with highlights overlaid
"""
# Convert to RGB for processing
original_mode = image.mode
if image.mode == 'RGBA':
rgb_image = image.convert('RGB')
alpha_channel = image.split()[-1]
else:
rgb_image = image.convert('RGB')
alpha_channel = None
# Convert to numpy array for efficient processing
img_array = np.array(rgb_image, dtype=np.float32)
# Process each highlight
for highlight in highlights:
# Extract RGB components from highlight color (ignore alpha)
h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2]
# Create highlight multiplier (normalize to 0-1 range)
highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32)
for hx, hy, hw, hh in highlight.bounds:
# Ensure bounds are within image
hx, hy = max(0, hx), max(0, hy)
x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh)
if x2 <= hx or y2 <= hy:
continue
# Extract the region to highlight
region = img_array[hy:y2, hx:x2, :]
# Multiply with highlight color (like a real highlighter)
highlighted = region * highlight_color
# Put the highlighted region back
img_array[hy:y2, hx:x2, :] = highlighted
# Convert back to uint8 and create PIL Image
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
result = Image.fromarray(img_array, mode='RGB')
# Restore alpha channel if original had one
if alpha_channel is not None and original_mode == 'RGBA':
result = result.convert('RGBA')
result.putalpha(alpha_channel)
return result

View File

@ -0,0 +1,250 @@
"""
Settings and rendering configuration management.
This module handles font size, spacing, and other rendering settings.
"""
from __future__ import annotations
from typing import Dict, Any, Optional
from PIL import Image
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
class SettingsManager:
"""
Manages font size, spacing, and rendering settings.
Responsibilities:
- Font scale adjustment
- Line spacing control
- Inter-block spacing control
- Word spacing control
- Settings persistence helpers
"""
def __init__(self):
"""Initialize the settings manager."""
self.font_scale = 1.0
self.font_scale_step = 0.1 # 10% change per step
self.manager: Optional[EreaderLayoutManager] = None
def set_manager(self, manager: EreaderLayoutManager):
"""
Set the layout manager to control.
Args:
manager: EreaderLayoutManager instance to manage settings for
"""
self.manager = manager
self.font_scale = manager.font_scale
def set_font_size(self, scale: float) -> Optional[Image.Image]:
"""
Set the font size scale and re-render current page.
Args:
scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size)
Returns:
Rendered page with new font size, or None if no manager
"""
if not self.manager:
return None
try:
self.font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x
page = self.manager.set_font_scale(self.font_scale)
return page.render() if page else None
except Exception as e:
print(f"Error setting font size: {e}")
return None
def increase_font_size(self) -> Optional[Image.Image]:
"""
Increase font size by one step and re-render.
Returns:
Rendered page with increased font size
"""
new_scale = self.font_scale + self.font_scale_step
return self.set_font_size(new_scale)
def decrease_font_size(self) -> Optional[Image.Image]:
"""
Decrease font size by one step and re-render.
Returns:
Rendered page with decreased font size
"""
new_scale = self.font_scale - self.font_scale_step
return self.set_font_size(new_scale)
def get_font_size(self) -> float:
"""
Get the current font size scale.
Returns:
Current font scale factor
"""
return self.font_scale
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set line spacing using pyWebLayout's native support.
Args:
spacing: Line spacing in pixels
Returns:
Rendered page with new line spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.line_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_line_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_line_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting line spacing: {e}")
return None
def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set inter-block spacing using pyWebLayout's native support.
Args:
spacing: Inter-block spacing in pixels
Returns:
Rendered page with new inter-block spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.inter_block_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_inter_block_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_inter_block_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting inter-block spacing: {e}")
return None
def set_word_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set word spacing using pyWebLayout's native support.
Args:
spacing: Word spacing in pixels
Returns:
Rendered page with new word spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.word_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_word_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_word_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting word spacing: {e}")
return None
def get_current_settings(self) -> Dict[str, Any]:
"""
Get current rendering settings.
Returns:
Dictionary with all current settings
"""
if not self.manager:
return {
'font_scale': self.font_scale,
'line_spacing': 5,
'inter_block_spacing': 15,
'word_spacing': 0
}
return {
'font_scale': self.font_scale,
'line_spacing': self.manager.page_style.line_spacing,
'inter_block_spacing': self.manager.page_style.inter_block_spacing,
'word_spacing': self.manager.page_style.word_spacing
}
def apply_settings(self, settings: Dict[str, Any]) -> bool:
"""
Apply rendering settings from a settings dictionary.
This should be called after loading a book to restore user preferences.
Args:
settings: Dictionary with settings (font_scale, line_spacing, etc.)
Returns:
True if settings applied successfully, False otherwise
"""
if not self.manager:
return False
try:
# Apply font scale
font_scale = settings.get('font_scale', 1.0)
if font_scale != self.font_scale:
self.set_font_size(font_scale)
# Apply line spacing
line_spacing = settings.get('line_spacing', 5)
if line_spacing != self.manager.page_style.line_spacing:
self.set_line_spacing(line_spacing)
# Apply inter-block spacing
inter_block_spacing = settings.get('inter_block_spacing', 15)
if inter_block_spacing != self.manager.page_style.inter_block_spacing:
self.set_inter_block_spacing(inter_block_spacing)
# Apply word spacing
word_spacing = settings.get('word_spacing', 0)
if word_spacing != self.manager.page_style.word_spacing:
self.set_word_spacing(word_spacing)
return True
except Exception as e:
print(f"Error applying settings: {e}")
return False

View File

@ -9,8 +9,12 @@ The `EbookReader` class provides a complete, user-friendly interface for buildin
- 🔖 **Position Management** - Save/load reading positions (stable across font changes) - 🔖 **Position Management** - Save/load reading positions (stable across font changes)
- 📑 **Chapter Navigation** - Jump to chapters by title or index - 📑 **Chapter Navigation** - Jump to chapters by title or index
- 🔤 **Font Size Control** - Increase/decrease font size with live re-rendering - 🔤 **Font Size Control** - Increase/decrease font size with live re-rendering
- 📏 **Spacing Control** - Adjust line and block spacing - 📏 **Spacing Control** - Adjust line, block, and word spacing
- 💾 **Persistent Settings** - Save and restore rendering preferences across sessions
- 📊 **Progress Tracking** - Get reading progress and position information - 📊 **Progress Tracking** - Get reading progress and position information
- 🎨 **Text Highlighting** - Highlight words and passages with colors
- 📋 **Overlays** - TOC, Settings, and Bookmarks overlays
- 🖱️ **Gesture Support** - Handle tap, swipe, pinch gestures
- 💾 **Context Manager Support** - Automatic cleanup with `with` statement - 💾 **Context Manager Support** - Automatic cleanup with `with` statement
## Quick Start ## Quick Start
@ -235,12 +239,66 @@ with EbookReader(
print(f"Reading progress: {progress*100:.1f}%") print(f"Reading progress: {progress*100:.1f}%")
``` ```
## Demo Script ## Persistent Settings
Run the comprehensive demo to see all features in action: Settings like font size and spacing are automatically saved and restored across sessions:
```python
from dreader import EbookReader
from dreader.state import StateManager
from pathlib import Path
# Initialize state manager
state_file = Path.home() / ".config" / "dreader" / "state.json"
state_manager = StateManager(state_file=state_file)
# Load saved state
state = state_manager.load_state()
print(f"Saved font scale: {state.settings.font_scale}")
# Create reader with saved settings
reader = EbookReader(
line_spacing=state.settings.line_spacing,
inter_block_spacing=state.settings.inter_block_spacing
)
# Load book and apply all saved settings
reader.load_epub("mybook.epub")
reader.apply_settings(state.settings.to_dict())
# User changes settings...
reader.increase_font_size()
reader.set_line_spacing(10)
# Save new settings for next session
current_settings = reader.get_current_settings()
state_manager.update_settings(current_settings)
state_manager.save_state()
# Next time the app starts, these settings will be restored!
```
See [persistent_settings_example.py](persistent_settings_example.py) for a complete demonstration.
## Demo Scripts
Run these demos to see features in action:
```bash ```bash
# Comprehensive feature demo
python examples/ereader_demo.py path/to/book.epub python examples/ereader_demo.py path/to/book.epub
# Persistent settings demo
python examples/persistent_settings_example.py
# TOC overlay demo (generates animated GIF)
python examples/demo_toc_overlay.py
# Settings overlay demo (generates animated GIF)
python examples/demo_settings_overlay.py
# Word highlighting examples
python examples/word_selection_highlighting.py
``` ```
This will demonstrate: This will demonstrate:

View File

@ -166,9 +166,11 @@ class TestTOCOverlay(unittest.TestCase):
self.skipTest("Need at least 2 chapters for this test") self.skipTest("Need at least 2 chapters for this test")
# Calculate tap position for second chapter (index 1 - "Metamorphosis") # Calculate tap position for second chapter (index 1 - "Metamorphosis")
# Based on actual measurements: chapter 1 link is at screen Y=282, X=200-300 # Based on actual measurements from pyWebLayout query_point:
# Overlay bounds: (38, 138, 122, 16) -> X=[38,160], Y=[138,154]
# With panel offset (160, 180): Screen X=[198,320], Y=[318,334]
tap_x = 250 # Within the link text bounds tap_x = 250 # Within the link text bounds
tap_y = 282 # Chapter 1 "Metamorphosis" tap_y = 335 # Chapter 1 "Metamorphosis" at overlay Y=155 (138+16=154, screen 180+155=335)
event = TouchEvent( event = TouchEvent(
gesture=GestureType.TAP, gesture=GestureType.TAP,

1
tests/unit/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Unit tests for dreader modules."""

View File

@ -0,0 +1 @@
"""Unit tests for manager modules."""

View File

@ -0,0 +1,164 @@
"""
Unit tests for DocumentManager.
Tests document loading in isolation without full EbookReader.
"""
import unittest
import tempfile
import os
from pathlib import Path
from dreader.managers.document import DocumentManager
class TestDocumentManager(unittest.TestCase):
"""Test DocumentManager in isolation"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
self.manager = DocumentManager()
def tearDown(self):
"""Clean up"""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_initialization(self):
"""Test manager initializes correctly"""
manager = DocumentManager()
self.assertIsNone(manager.document_id)
self.assertIsNone(manager.title)
self.assertIsNone(manager.author)
self.assertIsNone(manager.blocks)
self.assertFalse(manager.is_loaded())
def test_load_valid_epub(self):
"""Test loading a valid EPUB file"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
success = self.manager.load_epub(self.epub_path)
self.assertTrue(success)
self.assertTrue(self.manager.is_loaded())
self.assertIsNotNone(self.manager.document_id)
self.assertIsNotNone(self.manager.title)
self.assertIsNotNone(self.manager.author)
self.assertIsNotNone(self.manager.blocks)
self.assertGreater(len(self.manager.blocks), 0)
def test_load_nonexistent_epub(self):
"""Test loading a non-existent EPUB file"""
success = self.manager.load_epub("nonexistent.epub")
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_load_invalid_epub(self):
"""Test loading an invalid file as EPUB"""
# Create a temporary invalid file
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
with open(invalid_path, 'w') as f:
f.write("This is not a valid EPUB file")
success = self.manager.load_epub(invalid_path)
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_load_html_success(self):
"""Test loading HTML content"""
html = """
<html>
<body>
<h1>Test Document</h1>
<p>This is a test paragraph.</p>
</body>
</html>
"""
success = self.manager.load_html(
html,
title="Test HTML",
author="Test Author",
document_id="test_html"
)
self.assertTrue(success)
self.assertTrue(self.manager.is_loaded())
self.assertEqual(self.manager.title, "Test HTML")
self.assertEqual(self.manager.author, "Test Author")
self.assertEqual(self.manager.document_id, "test_html")
self.assertGreater(len(self.manager.blocks), 0)
def test_load_empty_html(self):
"""Test loading empty HTML"""
success = self.manager.load_html("")
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_get_metadata(self):
"""Test getting document metadata"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
metadata = self.manager.get_metadata()
self.assertIsInstance(metadata, dict)
self.assertIn('title', metadata)
self.assertIn('author', metadata)
self.assertIn('document_id', metadata)
self.assertIn('total_blocks', metadata)
self.assertGreater(metadata['total_blocks'], 0)
def test_get_blocks(self):
"""Test getting content blocks"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
blocks = self.manager.get_blocks()
self.assertIsInstance(blocks, list)
self.assertGreater(len(blocks), 0)
def test_clear(self):
"""Test clearing loaded document"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
self.assertTrue(self.manager.is_loaded())
self.manager.clear()
self.assertFalse(self.manager.is_loaded())
self.assertIsNone(self.manager.document_id)
self.assertIsNone(self.manager.title)
self.assertIsNone(self.manager.author)
self.assertIsNone(self.manager.blocks)
def test_multiple_loads(self):
"""Test loading multiple documents sequentially"""
html1 = "<html><body><h1>Document 1</h1></body></html>"
html2 = "<html><body><h1>Document 2</h1></body></html>"
# Load first document
self.manager.load_html(html1, title="Doc 1", document_id="doc1")
self.assertEqual(self.manager.title, "Doc 1")
self.assertEqual(self.manager.document_id, "doc1")
# Load second document (should replace first)
self.manager.load_html(html2, title="Doc 2", document_id="doc2")
self.assertEqual(self.manager.title, "Doc 2")
self.assertEqual(self.manager.document_id, "doc2")
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,220 @@
"""
Unit tests for SettingsManager.
Tests settings management in isolation using mocks.
"""
import unittest
from unittest.mock import Mock, MagicMock
from dreader.managers.settings import SettingsManager
class TestSettingsManager(unittest.TestCase):
"""Test SettingsManager in isolation"""
def setUp(self):
"""Set up test environment"""
self.manager = SettingsManager()
def test_initialization(self):
"""Test manager initializes correctly"""
manager = SettingsManager()
self.assertEqual(manager.font_scale, 1.0)
self.assertEqual(manager.font_scale_step, 0.1)
self.assertIsNone(manager.manager)
def test_get_font_size(self):
"""Test getting current font size"""
self.assertEqual(self.manager.get_font_size(), 1.0)
self.manager.font_scale = 1.5
self.assertEqual(self.manager.get_font_size(), 1.5)
def test_set_font_size_without_manager(self):
"""Test setting font size without layout manager returns None"""
result = self.manager.set_font_size(1.5)
self.assertIsNone(result)
def test_set_font_size_with_manager(self):
"""Test setting font size with layout manager"""
# Create mock manager
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
# Set font size
result = self.manager.set_font_size(1.5)
self.assertIsNotNone(result)
self.assertEqual(result, "rendered_page")
self.assertEqual(self.manager.font_scale, 1.5)
mock_layout_manager.set_font_scale.assert_called_once_with(1.5)
def test_font_size_clamping(self):
"""Test font size is clamped between 0.5x and 3.0x"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
# Test upper bound
self.manager.set_font_size(5.0)
self.assertEqual(self.manager.font_scale, 3.0)
# Test lower bound
self.manager.set_font_size(0.1)
self.assertEqual(self.manager.font_scale, 0.5)
def test_increase_font_size(self):
"""Test increasing font size by one step"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
initial_size = self.manager.font_scale
self.manager.increase_font_size()
self.assertEqual(self.manager.font_scale, initial_size + 0.1)
def test_decrease_font_size(self):
"""Test decreasing font size by one step"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.5
self.manager.set_manager(mock_layout_manager)
self.manager.font_scale = 1.5
self.manager.decrease_font_size()
self.assertAlmostEqual(self.manager.font_scale, 1.4, places=5)
def test_set_line_spacing(self):
"""Test setting line spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 5
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_line_spacing(10)
self.assertIsNotNone(result)
mock_layout_manager.increase_line_spacing.assert_called_once_with(5)
def test_set_inter_block_spacing(self):
"""Test setting inter-block spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.inter_block_spacing = 15
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_inter_block_spacing(25)
self.assertIsNotNone(result)
mock_layout_manager.increase_inter_block_spacing.assert_called_once_with(10)
def test_set_word_spacing(self):
"""Test setting word spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.word_spacing = 0
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_word_spacing(3)
self.assertIsNotNone(result)
mock_layout_manager.increase_word_spacing.assert_called_once_with(3)
def test_get_current_settings_without_manager(self):
"""Test getting settings without layout manager"""
settings = self.manager.get_current_settings()
self.assertIsInstance(settings, dict)
self.assertEqual(settings['font_scale'], 1.0)
self.assertEqual(settings['line_spacing'], 5)
self.assertEqual(settings['inter_block_spacing'], 15)
self.assertEqual(settings['word_spacing'], 0)
def test_get_current_settings_with_manager(self):
"""Test getting settings with layout manager"""
mock_layout_manager = Mock()
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 10
mock_layout_manager.page_style.inter_block_spacing = 20
mock_layout_manager.page_style.word_spacing = 3
mock_layout_manager.font_scale = 1.5
self.manager.set_manager(mock_layout_manager)
self.manager.font_scale = 1.5
settings = self.manager.get_current_settings()
self.assertEqual(settings['font_scale'], 1.5)
self.assertEqual(settings['line_spacing'], 10)
self.assertEqual(settings['inter_block_spacing'], 20)
self.assertEqual(settings['word_spacing'], 3)
def test_apply_settings(self):
"""Test applying settings from dictionary"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 5
mock_layout_manager.page_style.inter_block_spacing = 15
mock_layout_manager.page_style.word_spacing = 0
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
settings = {
'font_scale': 1.5,
'line_spacing': 10,
'inter_block_spacing': 20,
'word_spacing': 3
}
success = self.manager.apply_settings(settings)
self.assertTrue(success)
self.assertEqual(self.manager.font_scale, 1.5)
def test_apply_settings_without_manager(self):
"""Test applying settings without layout manager returns False"""
settings = {'font_scale': 1.5}
success = self.manager.apply_settings(settings)
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()