refactor applications to delegate responsibilites
Some checks failed
Python CI / test (push) Failing after 4m11s
Some checks failed
Python CI / test (push) Failing after 4m11s
This commit is contained in:
parent
4811367905
commit
fe140ba91f
@ -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
|
||||||
|
|||||||
10
dreader/handlers/__init__.py
Normal file
10
dreader/handlers/__init__.py
Normal 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']
|
||||||
282
dreader/handlers/gestures.py
Normal file
282
dreader/handlers/gestures.py
Normal 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, {})
|
||||||
14
dreader/managers/__init__.py
Normal file
14
dreader/managers/__init__.py
Normal 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']
|
||||||
137
dreader/managers/document.py
Normal file
137
dreader/managers/document.py
Normal 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
|
||||||
211
dreader/managers/highlight_coordinator.py
Normal file
211
dreader/managers/highlight_coordinator.py
Normal 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
|
||||||
250
dreader/managers/settings.py
Normal file
250
dreader/managers/settings.py
Normal 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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
1
tests/unit/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for dreader modules."""
|
||||||
1
tests/unit/managers/__init__.py
Normal file
1
tests/unit/managers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for manager modules."""
|
||||||
164
tests/unit/managers/test_document.py
Normal file
164
tests/unit/managers/test_document.py
Normal 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()
|
||||||
220
tests/unit/managers/test_settings.py
Normal file
220
tests/unit/managers/test_settings.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user