Update coverage badges [skip ci]
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
pyWebLayout-ereader: A complete ebook reader application built with pyWebLayout.
|
||||
|
||||
This package provides a high-level, user-friendly ebook reader implementation
|
||||
with all essential features for building ereader applications.
|
||||
"""
|
||||
|
||||
from dreader.application import EbookReader, create_ebook_reader
|
||||
from dreader import html_generator
|
||||
from dreader import book_utils
|
||||
from dreader.gesture import (
|
||||
TouchEvent,
|
||||
GestureType,
|
||||
GestureResponse,
|
||||
ActionType
|
||||
)
|
||||
from dreader.state import (
|
||||
StateManager,
|
||||
AppState,
|
||||
BookState,
|
||||
LibraryState,
|
||||
Settings,
|
||||
EreaderMode,
|
||||
OverlayState
|
||||
)
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
# Core reader
|
||||
"EbookReader",
|
||||
"create_ebook_reader",
|
||||
|
||||
# Utilities
|
||||
"html_generator",
|
||||
"book_utils",
|
||||
|
||||
# Gesture system
|
||||
"TouchEvent",
|
||||
"GestureType",
|
||||
"GestureResponse",
|
||||
"ActionType",
|
||||
|
||||
# State management
|
||||
"StateManager",
|
||||
"AppState",
|
||||
"BookState",
|
||||
"LibraryState",
|
||||
"Settings",
|
||||
"EreaderMode",
|
||||
"OverlayState",
|
||||
|
||||
# Library
|
||||
"LibraryManager",
|
||||
|
||||
# Main application
|
||||
"DReaderApplication",
|
||||
"AppConfig",
|
||||
|
||||
# HAL interfaces
|
||||
"DisplayHAL",
|
||||
"KeyboardInputHAL",
|
||||
"EventLoopHAL",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Utilities for managing book library, scanning EPUBs, and extracting metadata.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from dreader import create_ebook_reader
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
import ebooklib
|
||||
from ebooklib import epub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Scan a directory for EPUB files and extract metadata.
|
||||
|
||||
Args:
|
||||
directory: Path to directory containing EPUB files
|
||||
|
||||
Returns:
|
||||
List of book dictionaries with metadata
|
||||
"""
|
||||
books = []
|
||||
epub_files = list(directory.glob('*.epub'))
|
||||
|
||||
for epub_path in epub_files:
|
||||
metadata = extract_book_metadata(epub_path)
|
||||
if metadata:
|
||||
books.append(metadata)
|
||||
|
||||
return sorted(books, key=lambda b: b['title'].lower())
|
||||
|
||||
|
||||
def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Extract metadata from an EPUB file.
|
||||
|
||||
Args:
|
||||
epub_path: Path to EPUB file
|
||||
include_cover: Whether to extract and include cover image as base64
|
||||
|
||||
Returns:
|
||||
Dictionary with book metadata or None if extraction fails
|
||||
"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Create temporary reader to extract metadata
|
||||
reader_start = time.time()
|
||||
reader = create_ebook_reader(page_size=(400, 600))
|
||||
reader.load_epub(str(epub_path))
|
||||
reader_elapsed = time.time() - reader_start
|
||||
|
||||
logger.debug(f"[METADATA] Loaded EPUB {epub_path.name} in {reader_elapsed:.2f}s")
|
||||
|
||||
metadata = {
|
||||
'filename': epub_path.name,
|
||||
'path': str(epub_path),
|
||||
'title': reader.book_title or epub_path.stem,
|
||||
'author': reader.book_author or 'Unknown Author',
|
||||
}
|
||||
|
||||
# Extract cover image if requested - use direct EPUB extraction
|
||||
if include_cover:
|
||||
cover_start = time.time()
|
||||
cover_data = extract_cover_from_epub(epub_path)
|
||||
cover_elapsed = time.time() - cover_start
|
||||
metadata['cover_data'] = cover_data
|
||||
logger.debug(f"[METADATA] Extracted cover from {epub_path.name} in {cover_elapsed:.2f}s")
|
||||
|
||||
total_elapsed = time.time() - start_time
|
||||
logger.info(f"[METADATA] Extracted metadata from '{metadata['title']}' in {total_elapsed:.2f}s")
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata from {epub_path}: {e}")
|
||||
print(f"Error extracting metadata from {epub_path}: {e}")
|
||||
return {
|
||||
'filename': epub_path.name,
|
||||
'path': str(epub_path),
|
||||
'title': epub_path.stem,
|
||||
'author': 'Unknown',
|
||||
'cover_data': None
|
||||
}
|
||||
|
||||
|
||||
def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450) -> Optional[str]:
|
||||
"""
|
||||
Extract cover image from reader and return as base64 encoded string.
|
||||
|
||||
This function is kept for backward compatibility but now uses extract_cover_from_epub
|
||||
internally if the reader has an epub_path attribute.
|
||||
|
||||
Args:
|
||||
reader: EbookReader instance with loaded book
|
||||
max_width: Maximum width for cover image
|
||||
max_height: Maximum height for cover image
|
||||
|
||||
Returns:
|
||||
Base64 encoded PNG image string or None
|
||||
"""
|
||||
try:
|
||||
# If the reader has an epub path, try to extract actual cover
|
||||
if hasattr(reader, '_epub_path') and reader._epub_path:
|
||||
return extract_cover_from_epub(reader._epub_path, max_width, max_height)
|
||||
|
||||
# Fallback to first page as cover
|
||||
cover_image = reader.get_current_page()
|
||||
|
||||
# Resize if needed
|
||||
if cover_image.width > max_width or cover_image.height > max_height:
|
||||
cover_image.thumbnail((max_width, max_height))
|
||||
|
||||
# Convert to base64
|
||||
buffer = BytesIO()
|
||||
cover_image.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
|
||||
return img_base64
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting cover image: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: int = 450) -> Optional[str]:
|
||||
"""
|
||||
Extract the actual cover image from an EPUB file.
|
||||
|
||||
Args:
|
||||
epub_path: Path to EPUB file
|
||||
max_width: Maximum width for cover image
|
||||
max_height: Maximum height for cover image
|
||||
|
||||
Returns:
|
||||
Base64 encoded PNG image string or None
|
||||
"""
|
||||
try:
|
||||
# Read the EPUB
|
||||
read_start = time.time()
|
||||
book = epub.read_epub(str(epub_path))
|
||||
read_elapsed = time.time() - read_start
|
||||
logger.debug(f"[COVER] Read EPUB {epub_path.name} in {read_elapsed:.2f}s")
|
||||
|
||||
# Look for cover image
|
||||
cover_image = None
|
||||
search_start = time.time()
|
||||
|
||||
# First, try to find item marked as cover
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_COVER:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
logger.debug(f"[COVER] Found cover marked as ITEM_COVER in {epub_path.name}")
|
||||
break
|
||||
|
||||
# If not found, look for files with 'cover' in the name
|
||||
if not cover_image:
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_IMAGE:
|
||||
name = item.get_name().lower()
|
||||
if 'cover' in name:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
logger.debug(f"[COVER] Found cover by filename in {epub_path.name}")
|
||||
break
|
||||
|
||||
# If still not found, get the first image
|
||||
if not cover_image:
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_IMAGE:
|
||||
try:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
logger.debug(f"[COVER] Using first image as cover in {epub_path.name}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
search_elapsed = time.time() - search_start
|
||||
|
||||
if not cover_image:
|
||||
logger.debug(f"[COVER] No cover image found in {epub_path.name}")
|
||||
return None
|
||||
|
||||
# Resize if needed (maintain aspect ratio)
|
||||
process_start = time.time()
|
||||
if cover_image.width > max_width or cover_image.height > max_height:
|
||||
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
logger.debug(f"[COVER] Resized cover for {epub_path.name}")
|
||||
|
||||
# Convert to base64
|
||||
buffer = BytesIO()
|
||||
cover_image.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
process_elapsed = time.time() - process_start
|
||||
|
||||
logger.debug(f"[COVER] Processed cover for {epub_path.name}: search={search_elapsed:.2f}s, encode={process_elapsed:.2f}s")
|
||||
|
||||
return img_base64
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting cover from EPUB {epub_path}: {e}")
|
||||
print(f"Error extracting cover from EPUB {epub_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_chapter_list(reader) -> List[Dict]:
|
||||
"""
|
||||
Get formatted chapter list from reader.
|
||||
|
||||
Args:
|
||||
reader: EbookReader instance with loaded book
|
||||
|
||||
Returns:
|
||||
List of chapter dictionaries with index and title
|
||||
"""
|
||||
try:
|
||||
chapters = reader.get_chapters()
|
||||
result = []
|
||||
for i, chapter in enumerate(chapters):
|
||||
# Handle different chapter formats
|
||||
if isinstance(chapter, str):
|
||||
title = chapter
|
||||
elif isinstance(chapter, dict):
|
||||
title = chapter.get('title', f'Chapter {i+1}')
|
||||
elif isinstance(chapter, tuple) and len(chapter) >= 2:
|
||||
# Tuple format: (title, ...)
|
||||
title = chapter[0] if chapter[0] else f'Chapter {i+1}'
|
||||
else:
|
||||
title = f'Chapter {i+1}'
|
||||
|
||||
result.append({
|
||||
'index': i,
|
||||
'title': title
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error getting chapters: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_bookmark_list(reader) -> List[Dict]:
|
||||
"""
|
||||
Get formatted bookmark list from reader.
|
||||
|
||||
Args:
|
||||
reader: EbookReader instance with loaded book
|
||||
|
||||
Returns:
|
||||
List of bookmark dictionaries
|
||||
"""
|
||||
try:
|
||||
bookmarks = reader.list_saved_positions()
|
||||
return [
|
||||
{
|
||||
'name': bookmark,
|
||||
'position': '' # Could be enhanced to show chapter/page info
|
||||
}
|
||||
for bookmark in bookmarks
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error getting bookmarks: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def page_image_to_base64(page_image) -> str:
|
||||
"""
|
||||
Convert PIL Image to base64 encoded string.
|
||||
|
||||
Args:
|
||||
page_image: PIL Image object
|
||||
|
||||
Returns:
|
||||
Base64 encoded PNG string
|
||||
"""
|
||||
buffer = BytesIO()
|
||||
page_image.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
return base64.b64encode(img_bytes).decode('utf-8')
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Gesture event types for touch input.
|
||||
|
||||
This module defines touch gestures that can be received from a HAL (Hardware Abstraction Layer)
|
||||
or touch input system, and the response format for actions to be performed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class GestureType(Enum):
|
||||
"""Touch gesture types from HAL"""
|
||||
TAP = "tap" # Single finger tap
|
||||
LONG_PRESS = "long_press" # Hold for 500ms+
|
||||
SWIPE_LEFT = "swipe_left" # Swipe left (page forward)
|
||||
SWIPE_RIGHT = "swipe_right" # Swipe right (page back)
|
||||
SWIPE_UP = "swipe_up" # Swipe up (scroll down)
|
||||
SWIPE_DOWN = "swipe_down" # Swipe down (scroll up)
|
||||
PINCH_IN = "pinch_in" # Pinch fingers together (zoom out)
|
||||
PINCH_OUT = "pinch_out" # Spread fingers apart (zoom in)
|
||||
DRAG_START = "drag_start" # Start dragging/selection
|
||||
DRAG_MOVE = "drag_move" # Continue dragging
|
||||
DRAG_END = "drag_end" # End dragging/selection
|
||||
|
||||
# Accelerometer-based gestures
|
||||
TILT_FORWARD = "tilt_forward" # Tilt device forward (page forward)
|
||||
TILT_BACKWARD = "tilt_backward" # Tilt device backward (page back)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TouchEvent:
|
||||
"""
|
||||
Touch event from HAL.
|
||||
|
||||
Represents a single touch gesture with its coordinates and metadata.
|
||||
"""
|
||||
gesture: GestureType
|
||||
x: int # Primary touch point X coordinate
|
||||
y: int # Primary touch point Y coordinate
|
||||
x2: Optional[int] = None # Secondary point X (for pinch/drag)
|
||||
y2: Optional[int] = None # Secondary point Y (for pinch/drag)
|
||||
timestamp_ms: float = 0 # Timestamp in milliseconds
|
||||
|
||||
@classmethod
|
||||
def from_hal(cls, hal_data: dict) -> 'TouchEvent':
|
||||
"""
|
||||
Parse a touch event from HAL format.
|
||||
|
||||
Args:
|
||||
hal_data: Dictionary with gesture data from HAL
|
||||
Expected keys: 'gesture', 'x', 'y', optionally 'x2', 'y2', 'timestamp'
|
||||
|
||||
Returns:
|
||||
TouchEvent instance
|
||||
|
||||
Example:
|
||||
>>> event = TouchEvent.from_hal({
|
||||
... 'gesture': 'tap',
|
||||
... 'x': 450,
|
||||
... 'y': 320
|
||||
... })
|
||||
"""
|
||||
return cls(
|
||||
gesture=GestureType(hal_data['gesture']),
|
||||
x=hal_data['x'],
|
||||
y=hal_data['y'],
|
||||
x2=hal_data.get('x2'),
|
||||
y2=hal_data.get('y2'),
|
||||
timestamp_ms=hal_data.get('timestamp', 0)
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization"""
|
||||
return {
|
||||
'gesture': self.gesture.value,
|
||||
'x': self.x,
|
||||
'y': self.y,
|
||||
'x2': self.x2,
|
||||
'y2': self.y2,
|
||||
'timestamp_ms': self.timestamp_ms
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GestureResponse:
|
||||
"""
|
||||
Response from handling a gesture.
|
||||
|
||||
This encapsulates the action that should be performed by the UI
|
||||
in response to a gesture, keeping all business logic in the library.
|
||||
"""
|
||||
action: str # Action type: "navigate", "define", "select", "zoom", "page_turn", "none", etc.
|
||||
data: Dict[str, Any] # Action-specific data
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert to dictionary for Flask JSON response.
|
||||
|
||||
Returns:
|
||||
Dictionary with action and data
|
||||
"""
|
||||
return {
|
||||
'action': self.action,
|
||||
'data': self.data
|
||||
}
|
||||
|
||||
|
||||
# Action type constants for clarity
|
||||
class ActionType:
|
||||
"""Constants for gesture response action types"""
|
||||
NONE = "none"
|
||||
PAGE_TURN = "page_turn"
|
||||
NAVIGATE = "navigate"
|
||||
DEFINE = "define"
|
||||
SELECT = "select"
|
||||
ZOOM = "zoom"
|
||||
BOOK_LOADED = "book_loaded"
|
||||
WORD_SELECTED = "word_selected"
|
||||
SHOW_MENU = "show_menu"
|
||||
SELECTION_START = "selection_start"
|
||||
SELECTION_UPDATE = "selection_update"
|
||||
SELECTION_COMPLETE = "selection_complete"
|
||||
AT_START = "at_start"
|
||||
AT_END = "at_end"
|
||||
ERROR = "error"
|
||||
OVERLAY_OPENED = "overlay_opened"
|
||||
OVERLAY_CLOSED = "overlay_closed"
|
||||
CHAPTER_SELECTED = "chapter_selected"
|
||||
BOOKMARK_SELECTED = "bookmark_selected"
|
||||
TAB_SWITCHED = "tab_switched"
|
||||
SETTING_CHANGED = "setting_changed"
|
||||
BACK_TO_LIBRARY = "back_to_library"
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
GPIO Button Handler for DReader.
|
||||
|
||||
This module provides GPIO button support for physical buttons on the e-reader device.
|
||||
Buttons can be mapped to touch gestures for navigation and control.
|
||||
|
||||
Usage:
|
||||
from dreader.gpio_buttons import GPIOButtonHandler
|
||||
|
||||
buttons = GPIOButtonHandler(config)
|
||||
await buttons.initialize()
|
||||
|
||||
# Check for button events
|
||||
event = await buttons.get_button_event()
|
||||
if event:
|
||||
print(f"Button pressed: {event.gesture}")
|
||||
|
||||
await buttons.cleanup()
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .gesture import TouchEvent, GestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import RPi.GPIO
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
GPIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
GPIO_AVAILABLE = False
|
||||
logger.warning("RPi.GPIO not available. Button support disabled.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonConfig:
|
||||
"""Configuration for a single GPIO button."""
|
||||
name: str
|
||||
gpio: int
|
||||
gesture: GestureType
|
||||
description: str = ""
|
||||
pull_up: bool = True # True = pull-up (button pulls LOW), False = pull-down (button pulls HIGH)
|
||||
|
||||
|
||||
class GPIOButtonHandler:
|
||||
"""
|
||||
Handler for GPIO buttons that generates touch events.
|
||||
|
||||
This class manages physical buttons connected to GPIO pins and converts
|
||||
button presses into TouchEvent objects that can be handled by the application.
|
||||
|
||||
Args:
|
||||
buttons: List of ButtonConfig objects defining button mappings
|
||||
pull_up: Use pull-up resistors (default True)
|
||||
bounce_time_ms: Debounce time in milliseconds (default 200)
|
||||
screen_width: Screen width for generating touch coordinates (default 1872)
|
||||
screen_height: Screen height for generating touch coordinates (default 1404)
|
||||
|
||||
Example:
|
||||
buttons_config = [
|
||||
ButtonConfig("next", 23, GestureType.SWIPE_LEFT, "Next page"),
|
||||
ButtonConfig("prev", 24, GestureType.SWIPE_RIGHT, "Previous page"),
|
||||
]
|
||||
|
||||
handler = GPIOButtonHandler(buttons_config)
|
||||
await handler.initialize()
|
||||
|
||||
# In main loop
|
||||
event = await handler.get_button_event()
|
||||
if event:
|
||||
await app.handle_touch(event)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buttons: List[ButtonConfig],
|
||||
pull_up: bool = True,
|
||||
bounce_time_ms: int = 200,
|
||||
screen_width: int = 1872,
|
||||
screen_height: int = 1404,
|
||||
):
|
||||
"""Initialize GPIO button handler."""
|
||||
self.buttons = buttons
|
||||
self.pull_up = pull_up
|
||||
self.bounce_time_ms = bounce_time_ms
|
||||
self.screen_width = screen_width
|
||||
self.screen_height = screen_height
|
||||
|
||||
self._initialized = False
|
||||
self._event_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._gpio_map: Dict[int, ButtonConfig] = {}
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.error("RPi.GPIO not available. Buttons will not work.")
|
||||
return
|
||||
|
||||
logger.info(f"GPIO button handler created with {len(buttons)} buttons")
|
||||
for btn in buttons:
|
||||
active_type = "active low" if btn.pull_up else "active high"
|
||||
logger.info(f" Button '{btn.name}' on GPIO {btn.gpio} -> {btn.gesture.value} ({active_type})")
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize GPIO pins and set up button callbacks."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.warning("Cannot initialize buttons: RPi.GPIO not available")
|
||||
return
|
||||
|
||||
logger.info("Initializing GPIO buttons...")
|
||||
|
||||
# Set GPIO mode
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setwarnings(False)
|
||||
|
||||
# Configure each button
|
||||
for button in self.buttons:
|
||||
try:
|
||||
# Clean up any existing event detection on this pin
|
||||
try:
|
||||
GPIO.remove_event_detect(button.gpio)
|
||||
except Exception:
|
||||
pass # Ignore if no event detection was set
|
||||
|
||||
# Configure pin based on button's pull_up setting
|
||||
if button.pull_up:
|
||||
# Pull-up resistor: button pulls pin LOW when pressed
|
||||
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
||||
edge = GPIO.FALLING
|
||||
logger.debug(f"Button '{button.name}' configured with pull-up (active low)")
|
||||
else:
|
||||
# Pull-down resistor: button pulls pin HIGH when pressed
|
||||
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
|
||||
edge = GPIO.RISING
|
||||
logger.debug(f"Button '{button.name}' configured with pull-down (active high)")
|
||||
|
||||
# Add event detection with debounce
|
||||
GPIO.add_event_detect(
|
||||
button.gpio,
|
||||
edge,
|
||||
callback=lambda channel, btn=button: self._button_callback(btn),
|
||||
bouncetime=self.bounce_time_ms
|
||||
)
|
||||
|
||||
self._gpio_map[button.gpio] = button
|
||||
logger.info(f"✓ Configured button '{button.name}' on GPIO {button.gpio}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure button '{button.name}' on GPIO {button.gpio}: {e}")
|
||||
|
||||
self._initialized = True
|
||||
logger.info("GPIO buttons initialized successfully")
|
||||
|
||||
def _button_callback(self, button: ButtonConfig):
|
||||
"""
|
||||
Callback function for button press (runs in GPIO event thread).
|
||||
|
||||
This is called by RPi.GPIO when a button is pressed. We put the event
|
||||
in a queue for async processing.
|
||||
"""
|
||||
logger.debug(f"Button pressed: {button.name} (GPIO {button.gpio})")
|
||||
|
||||
# Create touch event
|
||||
# Use center of screen for button events (x, y don't matter for swipes)
|
||||
event = TouchEvent(
|
||||
gesture=button.gesture,
|
||||
x=self.screen_width // 2,
|
||||
y=self.screen_height // 2,
|
||||
)
|
||||
|
||||
# Put in queue (non-blocking)
|
||||
try:
|
||||
self._event_queue.put_nowait(event)
|
||||
logger.info(f"Button event queued: {button.name} -> {button.gesture.value}")
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Button event queue full, dropping event")
|
||||
|
||||
async def get_button_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Get the next button event from the queue.
|
||||
|
||||
Returns:
|
||||
TouchEvent if a button was pressed, None if no events
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Non-blocking get
|
||||
event = self._event_queue.get_nowait()
|
||||
return event
|
||||
except asyncio.QueueEmpty:
|
||||
return None
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up GPIO resources."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up GPIO buttons...")
|
||||
|
||||
try:
|
||||
# Remove event detection for all buttons
|
||||
for button in self.buttons:
|
||||
try:
|
||||
GPIO.remove_event_detect(button.gpio)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error removing event detect for GPIO {button.gpio}: {e}")
|
||||
|
||||
# Clean up GPIO
|
||||
GPIO.cleanup()
|
||||
logger.info("GPIO buttons cleaned up")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during GPIO cleanup: {e}")
|
||||
|
||||
self._initialized = False
|
||||
|
||||
|
||||
def load_button_config_from_dict(config: dict, screen_width: int = 1872, screen_height: int = 1404) -> Optional[GPIOButtonHandler]:
|
||||
"""
|
||||
Load GPIO button configuration from a dictionary.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with 'gpio_buttons' section
|
||||
screen_width: Screen width for touch coordinates
|
||||
screen_height: Screen height for touch coordinates
|
||||
|
||||
Returns:
|
||||
GPIOButtonHandler instance if buttons enabled, None otherwise
|
||||
|
||||
Example config:
|
||||
{
|
||||
"gpio_buttons": {
|
||||
"enabled": true,
|
||||
"pull_up": true, # Default pull_up for all buttons
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": [
|
||||
{
|
||||
"name": "next_page",
|
||||
"gpio": 23,
|
||||
"gesture": "swipe_left",
|
||||
"description": "Next page",
|
||||
"pull_up": true # Optional: override per button (true = active low, false = active high)
|
||||
},
|
||||
{
|
||||
"name": "power_off",
|
||||
"gpio": 21,
|
||||
"gesture": "long_press",
|
||||
"description": "Power off",
|
||||
"pull_up": false # Active high button
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
gpio_config = config.get("gpio_buttons", {})
|
||||
|
||||
if not gpio_config.get("enabled", False):
|
||||
logger.info("GPIO buttons disabled in config")
|
||||
return None
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.warning("GPIO buttons enabled in config but RPi.GPIO not available")
|
||||
return None
|
||||
|
||||
# Parse button configurations
|
||||
# Get default pull_up from global config for backward compatibility
|
||||
default_pull_up = gpio_config.get("pull_up", True)
|
||||
|
||||
buttons = []
|
||||
for btn_cfg in gpio_config.get("buttons", []):
|
||||
try:
|
||||
# Parse gesture type
|
||||
gesture_str = btn_cfg["gesture"]
|
||||
gesture = GestureType(gesture_str)
|
||||
|
||||
button = ButtonConfig(
|
||||
name=btn_cfg["name"],
|
||||
gpio=btn_cfg["gpio"],
|
||||
gesture=gesture,
|
||||
description=btn_cfg.get("description", ""),
|
||||
pull_up=btn_cfg.get("pull_up", default_pull_up) # Per-button or global default
|
||||
)
|
||||
buttons.append(button)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing button config: {e}")
|
||||
logger.error(f" Config: {btn_cfg}")
|
||||
continue
|
||||
|
||||
if not buttons:
|
||||
logger.warning("No valid button configurations found")
|
||||
return None
|
||||
|
||||
# Create handler
|
||||
handler = GPIOButtonHandler(
|
||||
buttons=buttons,
|
||||
pull_up=gpio_config.get("pull_up", True),
|
||||
bounce_time_ms=gpio_config.get("bounce_time_ms", 200),
|
||||
screen_width=screen_width,
|
||||
screen_height=screen_height,
|
||||
)
|
||||
|
||||
return handler
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Hardware Abstraction Layer (HAL) interface for DReader.
|
||||
|
||||
This module defines the abstract interface that platform-specific
|
||||
display/input implementations must provide.
|
||||
|
||||
The HAL separates the core e-reader logic from platform-specific
|
||||
hardware details (display, touch input, buttons, etc.).
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .gesture import TouchEvent
|
||||
|
||||
|
||||
class DisplayHAL(ABC):
|
||||
"""
|
||||
Abstract interface for display and input hardware.
|
||||
|
||||
Platform-specific implementations should subclass this and provide
|
||||
concrete implementations for all abstract methods.
|
||||
|
||||
The HAL is responsible for:
|
||||
- Displaying images on the screen
|
||||
- Capturing touch/click input and converting to TouchEvent
|
||||
- Hardware-specific features (brightness, sleep, etc.)
|
||||
|
||||
All methods are async to support non-blocking I/O.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display a PIL Image on the screen.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
|
||||
This method should handle:
|
||||
- Converting image format if needed for the display
|
||||
- Scaling/cropping if image size doesn't match display
|
||||
- Updating the physical display hardware
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Wait for and return the next touch event.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None if no event (non-blocking mode)
|
||||
|
||||
This method should:
|
||||
- Read from touch hardware
|
||||
- Convert raw coordinates to TouchEvent
|
||||
- Detect gesture type (tap, swipe, etc.)
|
||||
- Return None immediately if no event available
|
||||
|
||||
Note: For blocking behavior, implement a loop that awaits this
|
||||
method in the main event loop.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness.
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10, where 0=dimmest, 10=brightest)
|
||||
|
||||
Platform implementations should map this to their hardware's
|
||||
actual brightness range.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize the display hardware.
|
||||
|
||||
This optional method is called once before the application starts.
|
||||
Override to perform platform-specific initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
"""
|
||||
Clean up display hardware resources.
|
||||
|
||||
This optional method is called during application shutdown.
|
||||
Override to perform platform-specific cleanup.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def show_message(self, message: str, duration: float = 2.0):
|
||||
"""
|
||||
Display a text message (for loading screens, errors, etc.).
|
||||
|
||||
Args:
|
||||
message: Text message to display
|
||||
duration: How long to show message (seconds)
|
||||
|
||||
Default implementation creates a simple text image.
|
||||
Override for platform-specific message display.
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
# Create simple text image
|
||||
img = Image.new('RGB', (800, 1200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to use a decent font, fall back to default
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw centered text
|
||||
bbox = draw.textbbox((0, 0), message, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (800 - text_width) // 2
|
||||
y = (1200 - text_height) // 2
|
||||
|
||||
draw.text((x, y), message, fill=(0, 0, 0), font=font)
|
||||
|
||||
await self.show_image(img)
|
||||
|
||||
# Wait for duration
|
||||
if duration > 0:
|
||||
import asyncio
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
|
||||
class KeyboardInputHAL(ABC):
|
||||
"""
|
||||
Optional abstract interface for keyboard input.
|
||||
|
||||
This is separate from DisplayHAL to support platforms that have
|
||||
both touch and keyboard input (e.g., desktop testing).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_key_event(self) -> Optional[str]:
|
||||
"""
|
||||
Get the next keyboard event.
|
||||
|
||||
Returns:
|
||||
Key name as string (e.g., "up", "down", "enter", "q")
|
||||
None if no key event available
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EventLoopHAL(DisplayHAL):
|
||||
"""
|
||||
Extended HAL interface that provides its own event loop.
|
||||
|
||||
Some platforms (e.g., Pygame, Qt) have their own event loop that
|
||||
must be used. This interface allows the HAL to run the main loop
|
||||
and call back to the application.
|
||||
|
||||
Usage:
|
||||
hal = MyEventLoopHAL()
|
||||
app = DReaderApplication(AppConfig(display_hal=hal, ...))
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the platform's event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance to send events to
|
||||
|
||||
This method should:
|
||||
1. Initialize the display
|
||||
2. Call app.start()
|
||||
3. Enter event loop
|
||||
4. Call app.handle_touch(event) for each event
|
||||
5. Handle quit events and call app.shutdown()
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,675 @@
|
||||
"""
|
||||
Hardware HAL implementation using dreader-hal library.
|
||||
|
||||
This module provides the HardwareDisplayHAL class that bridges the DReader
|
||||
application HAL interface with the dreader-hal hardware abstraction layer.
|
||||
|
||||
The dreader-hal library provides complete e-ink display integration with:
|
||||
- IT8951 e-ink display driver
|
||||
- FT5xx6 capacitive touch sensor
|
||||
- BMA400 accelerometer (orientation)
|
||||
- PCF8523 RTC (timekeeping)
|
||||
- INA219 power monitor (battery)
|
||||
|
||||
Usage:
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
# For real hardware
|
||||
hal = HardwareDisplayHAL(width=800, height=1200, vcom=-2.0)
|
||||
|
||||
# For testing without hardware
|
||||
hal = HardwareDisplayHAL(
|
||||
width=800,
|
||||
height=1200,
|
||||
virtual_display=True,
|
||||
enable_orientation=False,
|
||||
enable_rtc=False,
|
||||
enable_power_monitor=False
|
||||
)
|
||||
|
||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
||||
app = DReaderApplication(config)
|
||||
|
||||
await hal.initialize()
|
||||
await app.start()
|
||||
|
||||
# Main loop
|
||||
while app.is_running():
|
||||
event = await hal.get_touch_event()
|
||||
if event:
|
||||
await app.handle_touch(event)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await app.shutdown()
|
||||
await hal.cleanup()
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from .hal import DisplayHAL
|
||||
from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import GPIO button support (only available on Raspberry Pi)
|
||||
try:
|
||||
from .gpio_buttons import GPIOButtonHandler, load_button_config_from_dict
|
||||
GPIO_BUTTONS_AVAILABLE = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
GPIO_BUTTONS_AVAILABLE = False
|
||||
logger.debug(f"GPIO buttons not available: {e}")
|
||||
|
||||
# Import dreader-hal components
|
||||
try:
|
||||
from dreader_hal import (
|
||||
EReaderDisplayHAL,
|
||||
TouchEvent as HalTouchEvent,
|
||||
GestureType as HalGestureType,
|
||||
RefreshMode,
|
||||
PowerStats,
|
||||
Orientation
|
||||
)
|
||||
DREADER_HAL_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
DREADER_HAL_AVAILABLE = False
|
||||
_import_error = e
|
||||
|
||||
|
||||
# Gesture type mapping between dreader-hal and dreader-application
|
||||
GESTURE_TYPE_MAP = {
|
||||
HalGestureType.TAP: AppGestureType.TAP,
|
||||
HalGestureType.LONG_PRESS: AppGestureType.LONG_PRESS,
|
||||
HalGestureType.SWIPE_LEFT: AppGestureType.SWIPE_LEFT,
|
||||
HalGestureType.SWIPE_RIGHT: AppGestureType.SWIPE_RIGHT,
|
||||
HalGestureType.SWIPE_UP: AppGestureType.SWIPE_UP,
|
||||
HalGestureType.SWIPE_DOWN: AppGestureType.SWIPE_DOWN,
|
||||
HalGestureType.PINCH_IN: AppGestureType.PINCH_IN,
|
||||
HalGestureType.PINCH_OUT: AppGestureType.PINCH_OUT,
|
||||
HalGestureType.DRAG_START: AppGestureType.DRAG_START,
|
||||
HalGestureType.DRAG_MOVE: AppGestureType.DRAG_MOVE,
|
||||
HalGestureType.DRAG_END: AppGestureType.DRAG_END,
|
||||
}
|
||||
|
||||
|
||||
class HardwareDisplayHAL(DisplayHAL):
|
||||
"""
|
||||
Hardware HAL implementation using dreader-hal library.
|
||||
|
||||
This class adapts the dreader-hal EReaderDisplayHAL to work with the
|
||||
DReader application's DisplayHAL interface.
|
||||
|
||||
Args:
|
||||
width: Display width in pixels (default 1872)
|
||||
height: Display height in pixels (default 1404)
|
||||
vcom: E-ink VCOM voltage (default -2.0, check device label!)
|
||||
spi_hz: SPI clock frequency (default 24MHz)
|
||||
virtual_display: Use virtual display for testing (default False)
|
||||
auto_sleep_display: Auto-sleep display after updates (default True)
|
||||
enable_orientation: Enable orientation sensing (default True)
|
||||
enable_rtc: Enable RTC timekeeping (default True)
|
||||
enable_power_monitor: Enable battery monitoring (default True)
|
||||
shunt_ohms: Power monitor shunt resistor (default 0.1)
|
||||
battery_capacity_mah: Battery capacity in mAh (default 3000)
|
||||
|
||||
Example:
|
||||
# For real hardware (Raspberry Pi with e-ink display)
|
||||
hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0)
|
||||
|
||||
# For testing on development machine
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
virtual_display=True,
|
||||
enable_orientation=False,
|
||||
enable_rtc=False,
|
||||
enable_power_monitor=False
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 1872,
|
||||
height: int = 1404,
|
||||
vcom: float = -2.0,
|
||||
spi_hz: int = 24_000_000,
|
||||
virtual_display: bool = False,
|
||||
auto_sleep_display: bool = True,
|
||||
enable_orientation: bool = True,
|
||||
enable_rtc: bool = True,
|
||||
enable_power_monitor: bool = True,
|
||||
shunt_ohms: float = 0.1,
|
||||
battery_capacity_mah: float = 3000,
|
||||
gpio_config: Optional[dict] = None,
|
||||
config_file: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize hardware HAL.
|
||||
|
||||
Args:
|
||||
gpio_config: GPIO button configuration dict (optional)
|
||||
config_file: Path to hardware_config.json file (optional, defaults to "hardware_config.json")
|
||||
|
||||
Raises:
|
||||
ImportError: If dreader-hal library is not installed
|
||||
"""
|
||||
if not DREADER_HAL_AVAILABLE:
|
||||
raise ImportError(
|
||||
f"dreader-hal library is required for HardwareDisplayHAL.\n"
|
||||
f"Install with: pip install -e external/dreader-hal\n"
|
||||
f"Original error: {_import_error}"
|
||||
)
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}")
|
||||
logger.info(f" VCOM: {vcom}V")
|
||||
logger.info(f" Virtual display: {virtual_display}")
|
||||
logger.info(f" Orientation: {enable_orientation}")
|
||||
logger.info(f" RTC: {enable_rtc}")
|
||||
logger.info(f" Power monitor: {enable_power_monitor}")
|
||||
|
||||
# Create the underlying dreader-hal implementation
|
||||
self.hal = EReaderDisplayHAL(
|
||||
width=width,
|
||||
height=height,
|
||||
vcom=vcom,
|
||||
spi_hz=spi_hz,
|
||||
virtual_display=virtual_display,
|
||||
auto_sleep_display=auto_sleep_display,
|
||||
enable_orientation=enable_orientation,
|
||||
enable_rtc=enable_rtc,
|
||||
enable_power_monitor=enable_power_monitor,
|
||||
shunt_ohms=shunt_ohms,
|
||||
battery_capacity_mah=battery_capacity_mah,
|
||||
)
|
||||
|
||||
# GPIO button handler (optional)
|
||||
self.gpio_handler: Optional[GPIOButtonHandler] = None
|
||||
|
||||
# Load GPIO config from file if specified
|
||||
if config_file or gpio_config is None:
|
||||
config_path = Path(config_file or "hardware_config.json")
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
full_config = json.load(f)
|
||||
gpio_config = full_config
|
||||
logger.info(f"Loaded hardware config from {config_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load hardware config from {config_path}: {e}")
|
||||
|
||||
# Initialize GPIO buttons if configured
|
||||
if gpio_config and GPIO_BUTTONS_AVAILABLE:
|
||||
try:
|
||||
self.gpio_handler = load_button_config_from_dict(
|
||||
gpio_config,
|
||||
screen_width=width,
|
||||
screen_height=height
|
||||
)
|
||||
if self.gpio_handler:
|
||||
logger.info("GPIO button handler created")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize GPIO buttons: {e}")
|
||||
elif gpio_config and not GPIO_BUTTONS_AVAILABLE:
|
||||
logger.info("GPIO buttons configured but RPi.GPIO not available (not on Raspberry Pi)")
|
||||
|
||||
self._initialized = False
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize all hardware components.
|
||||
|
||||
This initializes:
|
||||
- E-ink display controller
|
||||
- Touch sensor
|
||||
- Accelerometer (if enabled)
|
||||
- RTC (if enabled)
|
||||
- Power monitor (if enabled)
|
||||
- GPIO buttons (if configured)
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.warning("Hardware HAL already initialized")
|
||||
return
|
||||
|
||||
logger.info("Initializing hardware components...")
|
||||
await self.hal.initialize()
|
||||
|
||||
# Initialize GPIO buttons
|
||||
if self.gpio_handler:
|
||||
logger.info("Initializing GPIO buttons...")
|
||||
await self.gpio_handler.initialize()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Hardware HAL initialized successfully")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up all hardware resources."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up hardware HAL")
|
||||
|
||||
# Clean up GPIO buttons
|
||||
if self.gpio_handler:
|
||||
logger.info("Cleaning up GPIO buttons...")
|
||||
await self.gpio_handler.cleanup()
|
||||
|
||||
await self.hal.cleanup()
|
||||
self._initialized = False
|
||||
logger.info("Hardware HAL cleaned up")
|
||||
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display a PIL Image on the e-ink screen.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
|
||||
The dreader-hal library handles:
|
||||
- Format conversion (RGB -> grayscale)
|
||||
- Dithering for e-ink
|
||||
- Refresh mode selection (auto, fast, quality, full)
|
||||
- Orientation rotation (if enabled)
|
||||
"""
|
||||
if not self._initialized:
|
||||
logger.warning("Hardware HAL not initialized, initializing now...")
|
||||
await self.initialize()
|
||||
|
||||
logger.debug(f"Displaying image: {image.size} {image.mode}")
|
||||
await self.hal.show_image(image)
|
||||
|
||||
async def get_touch_event(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Get the next touch event from hardware (touch sensor or GPIO buttons).
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None if no event
|
||||
|
||||
The dreader-hal library handles gesture classification:
|
||||
- TAP: Quick tap (< 30px movement, < 300ms)
|
||||
- LONG_PRESS: Hold (< 30px movement, >= 500ms)
|
||||
- SWIPE_*: Directional swipes (>= 30px movement)
|
||||
- PINCH_IN/OUT: Two-finger pinch gestures
|
||||
|
||||
GPIO buttons are also polled and generate TouchEvent objects.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
# Check GPIO buttons first (they're more responsive)
|
||||
if self.gpio_handler:
|
||||
button_event = await self.gpio_handler.get_button_event()
|
||||
if button_event:
|
||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
||||
return button_event
|
||||
|
||||
# Get event from dreader-hal touch sensor
|
||||
hal_event = await self.hal.get_touch_event()
|
||||
|
||||
if hal_event is None:
|
||||
return None
|
||||
|
||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
||||
|
||||
if app_gesture is None:
|
||||
logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
||||
|
||||
return AppTouchEvent(
|
||||
gesture=app_gesture,
|
||||
x=hal_event.x,
|
||||
y=hal_event.y
|
||||
)
|
||||
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness.
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10)
|
||||
|
||||
Note:
|
||||
Basic IT8951 e-ink displays don't have brightness control.
|
||||
This is a no-op unless frontlight hardware is connected.
|
||||
"""
|
||||
if not 0 <= level <= 10:
|
||||
raise ValueError("Brightness must be 0-10")
|
||||
|
||||
logger.debug(f"Setting brightness to {level}")
|
||||
await self.hal.set_brightness(level)
|
||||
|
||||
# ========== Extended Methods (Hardware-Specific Features) ==========
|
||||
|
||||
async def get_battery_level(self) -> float:
|
||||
"""
|
||||
Get battery percentage.
|
||||
|
||||
Returns:
|
||||
Battery level 0-100%, or 0.0 if power monitor unavailable
|
||||
"""
|
||||
if not self._initialized:
|
||||
return 0.0
|
||||
|
||||
return await self.hal.get_battery_level()
|
||||
|
||||
async def get_power_stats(self) -> PowerStats:
|
||||
"""
|
||||
Get detailed power statistics.
|
||||
|
||||
Returns:
|
||||
PowerStats with voltage, current, power, battery %, etc.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If power monitor not enabled
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Hardware HAL not initialized")
|
||||
|
||||
return await self.hal.get_power_stats()
|
||||
|
||||
async def is_low_battery(self, threshold: float = 20.0) -> bool:
|
||||
"""
|
||||
Check if battery is low.
|
||||
|
||||
Args:
|
||||
threshold: Battery percentage threshold (default 20%)
|
||||
|
||||
Returns:
|
||||
True if battery below threshold, False otherwise
|
||||
"""
|
||||
if not self._initialized:
|
||||
return False
|
||||
|
||||
return await self.hal.is_low_battery(threshold)
|
||||
|
||||
async def set_low_power_mode(self, enabled: bool):
|
||||
"""
|
||||
Enable/disable low power mode.
|
||||
|
||||
In low power mode:
|
||||
- Display goes to sleep
|
||||
- Touch polling rate reduced
|
||||
- Sensors put to low power
|
||||
|
||||
Args:
|
||||
enabled: True to enable low power mode
|
||||
"""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info(f"Setting low power mode: {enabled}")
|
||||
await self.hal.set_low_power_mode(enabled)
|
||||
|
||||
async def enable_orientation_monitoring(self):
|
||||
"""
|
||||
Start monitoring device orientation changes.
|
||||
|
||||
When orientation changes, display auto-rotates.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Enabling orientation monitoring")
|
||||
await self.hal.enable_orientation_monitoring()
|
||||
|
||||
async def disable_orientation_monitoring(self):
|
||||
"""Stop monitoring orientation changes."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Disabling orientation monitoring")
|
||||
await self.hal.disable_orientation_monitoring()
|
||||
|
||||
@property
|
||||
def current_orientation(self) -> Optional[Orientation]:
|
||||
"""Get current device orientation."""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
return self.hal.current_orientation
|
||||
|
||||
@property
|
||||
def refresh_count(self) -> int:
|
||||
"""Get number of display refreshes since initialization."""
|
||||
if not self._initialized:
|
||||
return 0
|
||||
|
||||
return self.hal.refresh_count
|
||||
|
||||
async def get_datetime(self):
|
||||
"""
|
||||
Get current date/time from RTC.
|
||||
|
||||
Returns:
|
||||
struct_time with current date and time, or None if RTC unavailable
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
return await self.hal.get_datetime()
|
||||
|
||||
async def set_datetime(self, dt):
|
||||
"""
|
||||
Set the RTC date/time.
|
||||
|
||||
Args:
|
||||
dt: time.struct_time object with date and time to set
|
||||
|
||||
Raises:
|
||||
RuntimeError: If RTC not enabled
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Hardware HAL not initialized")
|
||||
|
||||
await self.hal.set_datetime(dt)
|
||||
|
||||
# ========== Accelerometer Tilt Detection ==========
|
||||
|
||||
def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool:
|
||||
"""
|
||||
Load accelerometer calibration from file.
|
||||
|
||||
Args:
|
||||
config_path: Path to calibration JSON file
|
||||
|
||||
Returns:
|
||||
True if calibration loaded successfully, False otherwise
|
||||
"""
|
||||
config_file = Path(config_path)
|
||||
if not config_file.exists():
|
||||
logger.warning(f"Accelerometer calibration file not found: {config_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Load up vector
|
||||
up = config.get("up_vector", {})
|
||||
self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
|
||||
|
||||
# Load thresholds
|
||||
self.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
|
||||
self.accel_debounce_time = config.get("debounce_time", 0.5)
|
||||
|
||||
# State tracking
|
||||
self.accel_last_tilt_time = 0
|
||||
|
||||
logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}")
|
||||
logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)")
|
||||
logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading accelerometer calibration: {e}")
|
||||
return False
|
||||
|
||||
async def get_tilt_gesture(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Check accelerometer for tilt gestures.
|
||||
|
||||
Returns:
|
||||
TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected,
|
||||
None otherwise
|
||||
|
||||
Note:
|
||||
Requires accelerometer calibration to be loaded first via
|
||||
load_accelerometer_calibration()
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
if not self.hal.orientation:
|
||||
return None
|
||||
|
||||
if not hasattr(self, 'accel_up_vector'):
|
||||
return None
|
||||
|
||||
# Get current acceleration
|
||||
try:
|
||||
ax, ay, az = await self.hal.orientation.get_acceleration()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading accelerometer: {e}")
|
||||
return None
|
||||
|
||||
# Check debounce
|
||||
current_time = time.time()
|
||||
if current_time - self.accel_last_tilt_time < self.accel_debounce_time:
|
||||
return None
|
||||
|
||||
# Calculate angle between current gravity and calibrated "up" vector
|
||||
# Gravity vector is the acceleration (pointing down)
|
||||
gx, gy, gz = ax, ay, az
|
||||
|
||||
# Normalize gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
if g_mag < 0.1:
|
||||
return None
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Normalize up vector
|
||||
ux, uy, uz = self.accel_up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
if u_mag < 0.1:
|
||||
return None
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
# Calculate tilt: project gravity onto the "forward/backward" axis
|
||||
# Forward/backward axis is perpendicular to up vector
|
||||
# We'll use the component of gravity that's perpendicular to the up vector
|
||||
|
||||
# Dot product: component of gravity along up vector
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
# Component of gravity perpendicular to up vector
|
||||
perp_x = gx - dot_up * ux
|
||||
perp_y = gy - dot_up * uy
|
||||
perp_z = gz - dot_up * uz
|
||||
|
||||
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
|
||||
|
||||
# Angle from vertical (in radians)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)")
|
||||
|
||||
# Check if tilted beyond threshold
|
||||
if tilt_angle < self.accel_tilt_threshold:
|
||||
return None
|
||||
|
||||
# Determine direction: forward or backward
|
||||
# We need to determine which direction the device is tilted
|
||||
# Use the sign of the perpendicular component along a reference axis
|
||||
|
||||
# For simplicity, we'll use the projection onto the original up vector's
|
||||
# perpendicular plane. If we tilt "forward", the gravity vector should
|
||||
# rotate in a specific direction.
|
||||
|
||||
# Calculate which direction: check if tilting away from or toward the up vector
|
||||
# If dot_up is decreasing (device tilting away from up), that's "forward"
|
||||
# If dot_up is increasing (device tilting back toward up), that's "backward"
|
||||
|
||||
# Actually, a simpler approach: check the direction of the perpendicular component
|
||||
# relative to a reference direction in the plane
|
||||
|
||||
# Let's define forward as tilting in the direction that increases the
|
||||
# y-component of acceleration (assuming standard orientation)
|
||||
# This is device-specific and may need adjustment
|
||||
|
||||
# For now, use a simple heuristic: forward = positive perpendicular y component
|
||||
if perp_y > 0:
|
||||
gesture = AppGestureType.TILT_FORWARD
|
||||
else:
|
||||
gesture = AppGestureType.TILT_BACKWARD
|
||||
|
||||
# Update debounce timer
|
||||
self.accel_last_tilt_time = current_time
|
||||
|
||||
logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)")
|
||||
|
||||
# Return gesture at center of screen (x, y don't matter for tilt)
|
||||
return AppTouchEvent(
|
||||
gesture=gesture,
|
||||
x=self.width // 2,
|
||||
y=self.height // 2,
|
||||
timestamp_ms=current_time * 1000
|
||||
)
|
||||
|
||||
async def get_event(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Get the next event from any input source (GPIO, touch, or accelerometer).
|
||||
|
||||
This is a convenience method that polls all input sources in a single call.
|
||||
Priority order: GPIO buttons > touch sensor > accelerometer tilt
|
||||
|
||||
Returns:
|
||||
TouchEvent from GPIO, touch sensor, or accelerometer, or None if no event
|
||||
|
||||
Usage:
|
||||
while running:
|
||||
event = await hal.get_event()
|
||||
if event:
|
||||
handle_gesture(event)
|
||||
await asyncio.sleep(0.01)
|
||||
"""
|
||||
# Check GPIO buttons first (most responsive)
|
||||
if self.gpio_handler:
|
||||
button_event = await self.gpio_handler.get_button_event()
|
||||
if button_event:
|
||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
||||
return button_event
|
||||
|
||||
# Check touch sensor (second priority)
|
||||
# Get event from dreader-hal touch sensor directly
|
||||
hal_event = await self.hal.get_touch_event()
|
||||
if hal_event is not None:
|
||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
||||
if app_gesture is not None:
|
||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
||||
return AppTouchEvent(
|
||||
gesture=app_gesture,
|
||||
x=hal_event.x,
|
||||
y=hal_event.y
|
||||
)
|
||||
|
||||
# Check accelerometer tilt (lowest priority)
|
||||
if hasattr(self, 'accel_up_vector'):
|
||||
tilt_event = await self.get_tilt_gesture()
|
||||
if tilt_event:
|
||||
return tilt_event
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Pygame-based Display HAL for desktop testing.
|
||||
|
||||
This HAL implementation uses Pygame to provide a desktop window
|
||||
for testing the e-reader application without physical hardware.
|
||||
|
||||
Features:
|
||||
- Window display with PIL image rendering
|
||||
- Mouse input converted to touch events
|
||||
- Keyboard shortcuts for common actions
|
||||
- Gesture detection (swipes via mouse drag)
|
||||
|
||||
Usage:
|
||||
from dreader.hal_pygame import PygameDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
hal = PygameDisplayHAL(width=800, height=1200)
|
||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
||||
app = DReaderApplication(config)
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from .hal import EventLoopHAL
|
||||
from .gesture import TouchEvent, GestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pygame is optional - only needed for desktop testing
|
||||
try:
|
||||
import pygame
|
||||
PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYGAME_AVAILABLE = False
|
||||
logger.warning("Pygame not available. Install with: pip install pygame")
|
||||
|
||||
|
||||
class PygameDisplayHAL(EventLoopHAL):
|
||||
"""
|
||||
Pygame-based display HAL for desktop testing.
|
||||
|
||||
This implementation provides a desktop window that simulates
|
||||
an e-reader display with touch input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 800,
|
||||
height: int = 1200,
|
||||
title: str = "DReader E-Book Reader",
|
||||
fullscreen: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize Pygame display.
|
||||
|
||||
Args:
|
||||
width: Window width in pixels
|
||||
height: Window height in pixels
|
||||
title: Window title
|
||||
fullscreen: If True, open in fullscreen mode
|
||||
"""
|
||||
if not PYGAME_AVAILABLE:
|
||||
raise RuntimeError("Pygame is required for PygameDisplayHAL. Install with: pip install pygame")
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.title = title
|
||||
self.fullscreen = fullscreen
|
||||
|
||||
self.screen = None
|
||||
self.running = False
|
||||
|
||||
# Touch/gesture tracking
|
||||
self.mouse_down_pos: Optional[tuple[int, int]] = None
|
||||
self.mouse_down_time: float = 0
|
||||
self.drag_threshold = 20 # pixels (reduced from 30 for easier swiping)
|
||||
self.long_press_duration = 0.5 # seconds
|
||||
|
||||
logger.info(f"PygameDisplayHAL initialized: {width}x{height}")
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize Pygame and create window."""
|
||||
logger.info("Initializing Pygame")
|
||||
pygame.init()
|
||||
|
||||
# Set up display
|
||||
flags = pygame.DOUBLEBUF
|
||||
if self.fullscreen:
|
||||
flags |= pygame.FULLSCREEN
|
||||
|
||||
self.screen = pygame.display.set_mode((self.width, self.height), flags)
|
||||
pygame.display.set_caption(self.title)
|
||||
|
||||
# Set up font for messages
|
||||
pygame.font.init()
|
||||
|
||||
logger.info("Pygame initialized successfully")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Pygame resources."""
|
||||
logger.info("Cleaning up Pygame")
|
||||
if pygame.get_init():
|
||||
pygame.quit()
|
||||
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display PIL image on Pygame window.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
"""
|
||||
if not self.screen:
|
||||
logger.warning("Screen not initialized")
|
||||
return
|
||||
|
||||
# Convert PIL image to pygame surface
|
||||
# PIL uses RGB, pygame uses RGB
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize if needed
|
||||
if image.size != (self.width, self.height):
|
||||
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to numpy array, then to pygame surface
|
||||
img_array = np.array(image)
|
||||
surface = pygame.surfarray.make_surface(np.transpose(img_array, (1, 0, 2)))
|
||||
|
||||
# Blit to screen
|
||||
self.screen.blit(surface, (0, 0))
|
||||
pygame.display.flip()
|
||||
|
||||
# Small delay to prevent excessive CPU usage
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Process pygame events and convert to TouchEvent.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None otherwise
|
||||
"""
|
||||
if not pygame.get_init():
|
||||
return None
|
||||
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
logger.info("Quit event received")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN:
|
||||
# Mouse down - start tracking for gesture
|
||||
self.mouse_down_pos = event.pos
|
||||
self.mouse_down_time = pygame.time.get_ticks() / 1000.0
|
||||
logger.info(f"[MOUSE] Button DOWN at {event.pos}")
|
||||
|
||||
elif event.type == pygame.MOUSEMOTION:
|
||||
# Show drag indicator while mouse is down
|
||||
if self.mouse_down_pos and pygame.mouse.get_pressed()[0]:
|
||||
current_pos = event.pos
|
||||
dx = current_pos[0] - self.mouse_down_pos[0]
|
||||
dy = current_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
|
||||
# Log dragging in progress
|
||||
if distance > 5: # Log any significant drag
|
||||
logger.info(f"[DRAG] Moving: dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px")
|
||||
|
||||
# Only show if dragging beyond threshold
|
||||
if distance > self.drag_threshold:
|
||||
# Draw a line showing the swipe direction
|
||||
if self.screen:
|
||||
# This is just for visual feedback during drag
|
||||
# The actual gesture detection happens on mouse up
|
||||
pass
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONUP:
|
||||
if self.mouse_down_pos is None:
|
||||
logger.warning("[MOUSE] Button UP but no down position recorded")
|
||||
continue
|
||||
|
||||
mouse_up_pos = event.pos
|
||||
mouse_up_time = pygame.time.get_ticks() / 1000.0
|
||||
|
||||
# Calculate distance and time
|
||||
dx = mouse_up_pos[0] - self.mouse_down_pos[0]
|
||||
dy = mouse_up_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
duration = mouse_up_time - self.mouse_down_time
|
||||
|
||||
logger.info(f"[MOUSE] Button UP at {mouse_up_pos}")
|
||||
logger.info(f"[GESTURE] dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px, duration={duration:.2f}s, threshold={self.drag_threshold}px")
|
||||
|
||||
# Detect gesture type
|
||||
gesture = None
|
||||
# For swipe gestures, use the starting position (mouse_down_pos)
|
||||
# For tap/long-press, use the ending position (mouse_up_pos)
|
||||
x, y = mouse_up_pos
|
||||
|
||||
if distance < self.drag_threshold:
|
||||
# Tap or long press
|
||||
if duration >= self.long_press_duration:
|
||||
gesture = GestureType.LONG_PRESS
|
||||
logger.info(f"[GESTURE] ✓ Detected: LONG_PRESS")
|
||||
else:
|
||||
gesture = GestureType.TAP
|
||||
logger.info(f"[GESTURE] ✓ Detected: TAP")
|
||||
else:
|
||||
# Swipe - use starting position for location-based checks
|
||||
x, y = self.mouse_down_pos
|
||||
if abs(dx) > abs(dy):
|
||||
# Horizontal swipe
|
||||
if dx > 0:
|
||||
gesture = GestureType.SWIPE_RIGHT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_RIGHT (dx={dx:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_LEFT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_LEFT (dx={dx:.0f})")
|
||||
else:
|
||||
# Vertical swipe
|
||||
if dy > 0:
|
||||
gesture = GestureType.SWIPE_DOWN
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_DOWN (dy={dy:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_UP
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_UP (dy={dy:.0f})")
|
||||
|
||||
# Reset tracking
|
||||
self.mouse_down_pos = None
|
||||
|
||||
if gesture:
|
||||
# For swipe gestures, (x,y) is the start position
|
||||
# For tap/long-press, (x,y) is the tap position
|
||||
logger.info(f"[EVENT] Returning TouchEvent: {gesture.value} at ({x}, {y})")
|
||||
return TouchEvent(gesture, x, y)
|
||||
else:
|
||||
logger.warning("[EVENT] No gesture detected (should not happen)")
|
||||
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
# Keyboard shortcuts
|
||||
return await self._handle_keyboard(event)
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_keyboard(self, event) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Handle keyboard shortcuts.
|
||||
|
||||
Args:
|
||||
event: Pygame keyboard event
|
||||
|
||||
Returns:
|
||||
TouchEvent equivalent of keyboard action
|
||||
"""
|
||||
# Arrow keys for page navigation
|
||||
if event.key == pygame.K_LEFT or event.key == pygame.K_PAGEUP:
|
||||
# Previous page
|
||||
return TouchEvent(GestureType.SWIPE_RIGHT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_RIGHT or event.key == pygame.K_PAGEDOWN or event.key == pygame.K_SPACE:
|
||||
# Next page
|
||||
return TouchEvent(GestureType.SWIPE_LEFT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_UP:
|
||||
# Scroll up (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_DOWN, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_DOWN:
|
||||
# Scroll down (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_UP, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
|
||||
# Quit
|
||||
logger.info("Quit via keyboard")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.key == pygame.K_EQUALS or event.key == pygame.K_PLUS:
|
||||
# Zoom in (pinch out)
|
||||
return TouchEvent(GestureType.PINCH_OUT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_MINUS:
|
||||
# Zoom out (pinch in)
|
||||
return TouchEvent(GestureType.PINCH_IN, self.width // 2, self.height // 2)
|
||||
|
||||
return None
|
||||
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness (not supported in Pygame).
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10)
|
||||
|
||||
Note: Brightness control is not available in Pygame.
|
||||
This is a no-op for desktop testing.
|
||||
"""
|
||||
logger.debug(f"Brightness set to {level} (not supported in Pygame)")
|
||||
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the Pygame event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance
|
||||
|
||||
This method:
|
||||
1. Initializes Pygame
|
||||
2. Starts the application
|
||||
3. Runs the event loop
|
||||
4. Handles events and updates display
|
||||
5. Shuts down gracefully
|
||||
"""
|
||||
logger.info("Starting Pygame event loop")
|
||||
|
||||
try:
|
||||
# Initialize
|
||||
await self.initialize()
|
||||
await app.start()
|
||||
|
||||
self.running = True
|
||||
|
||||
# Show instructions
|
||||
await self._show_instructions()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Main event loop
|
||||
clock = pygame.time.Clock()
|
||||
|
||||
while self.running and app.is_running():
|
||||
# Process events
|
||||
touch_event = await self.get_touch_event()
|
||||
|
||||
if touch_event:
|
||||
# Handle touch event
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
# Cap frame rate
|
||||
clock.tick(60) # 60 FPS max
|
||||
await asyncio.sleep(0.001) # Yield to other async tasks
|
||||
|
||||
logger.info("Event loop ended")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event loop: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await app.shutdown()
|
||||
await self.cleanup()
|
||||
|
||||
async def _show_instructions(self):
|
||||
"""Show keyboard instructions overlay."""
|
||||
if not self.screen:
|
||||
return
|
||||
|
||||
# Create instruction text
|
||||
font = pygame.font.Font(None, 24)
|
||||
instructions = [
|
||||
"DReader E-Book Reader",
|
||||
"",
|
||||
"Mouse Gestures:",
|
||||
" Drag LEFT (horizontal) = Next Page",
|
||||
" Drag RIGHT (horizontal) = Previous Page*",
|
||||
" Drag UP (vertical) = Navigation/TOC Overlay",
|
||||
" Drag DOWN (vertical) = Settings Overlay",
|
||||
"",
|
||||
"Keyboard Shortcuts:",
|
||||
" Space / Right Arrow = Next Page",
|
||||
" Left Arrow = Previous Page*",
|
||||
" +/- = Font Size",
|
||||
" Q/Escape = Quit",
|
||||
"",
|
||||
"*Previous page not working (pyWebLayout bug)",
|
||||
"",
|
||||
"Press any key to start..."
|
||||
]
|
||||
|
||||
# Create semi-transparent overlay
|
||||
overlay = pygame.Surface((self.width, self.height))
|
||||
overlay.fill((255, 255, 255))
|
||||
overlay.set_alpha(230)
|
||||
|
||||
# Render text
|
||||
y = 100
|
||||
for line in instructions:
|
||||
if line:
|
||||
text = font.render(line, True, (0, 0, 0))
|
||||
else:
|
||||
text = pygame.Surface((1, 20)) # Empty line
|
||||
text_rect = text.get_rect(center=(self.width // 2, y))
|
||||
overlay.blit(text, text_rect)
|
||||
y += 30
|
||||
|
||||
# Display
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
pygame.display.flip()
|
||||
@@ -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']
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
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)
|
||||
elif event.gesture == GestureType.TILT_FORWARD:
|
||||
return self._handle_page_forward()
|
||||
elif event.gesture == GestureType.TILT_BACKWARD:
|
||||
return self._handle_page_back()
|
||||
|
||||
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 Navigation overlay (TOC + Bookmarks)"""
|
||||
# Open navigation overlay from anywhere on screen
|
||||
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "navigation",
|
||||
"active_tab": "contents",
|
||||
"chapters": self.reader.get_chapters()
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
||||
"""Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)"""
|
||||
# Only open settings overlay if swipe starts from top 20% of screen
|
||||
top_threshold = self.reader.page_size[1] * 0.2
|
||||
if y > top_threshold:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
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, {})
|
||||
@@ -0,0 +1,763 @@
|
||||
"""
|
||||
HTML generation functions for dreader UI.
|
||||
|
||||
Generates HTML strings programmatically for library view, reader view,
|
||||
and various overlays (settings, TOC, etc.) that can be passed to a HAL
|
||||
for rendering.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool = False) -> str:
|
||||
"""
|
||||
Generate HTML for the library view showing all books in a simple table.
|
||||
|
||||
Args:
|
||||
books: List of book dictionaries with keys:
|
||||
- title: Book title
|
||||
- author: Book author
|
||||
- filename: EPUB filename
|
||||
- cover_data: Optional base64 encoded cover image
|
||||
- cover_path: Optional path to saved cover image (if save_covers_to_disk=True)
|
||||
save_covers_to_disk: If True, expect cover_path instead of cover_data
|
||||
|
||||
Returns:
|
||||
Complete HTML string for library view
|
||||
"""
|
||||
# Build table rows
|
||||
rows = []
|
||||
|
||||
for book in books:
|
||||
# Add cover image cell if available
|
||||
if save_covers_to_disk and book.get('cover_path'):
|
||||
cover_cell = f'<td><img src="{book["cover_path"]}" width="150"/></td>'
|
||||
elif book.get('cover_data'):
|
||||
cover_cell = f'<td><img src="data:image/png;base64,{book["cover_data"]}" width="150"/></td>'
|
||||
else:
|
||||
cover_cell = '<td>[No cover]</td>'
|
||||
|
||||
# Add book info cell
|
||||
info_cell = f'<td><b>{book["title"]}</b><br/>{book["author"]}</td>'
|
||||
|
||||
rows.append(f'<tr>{cover_cell}{info_cell}</tr>')
|
||||
|
||||
table_html = '\n'.join(rows)
|
||||
|
||||
return f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Library</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>My Library</h1>
|
||||
<p>{len(books)} books</p>
|
||||
<table>
|
||||
{table_html}
|
||||
</table>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str:
|
||||
"""
|
||||
Generate HTML for the reader view with page display.
|
||||
|
||||
Args:
|
||||
book_title: Title of current book
|
||||
book_author: Author of current book
|
||||
page_image_data: Base64 encoded page image
|
||||
|
||||
Returns:
|
||||
Complete HTML string for reader view (page layer only)
|
||||
"""
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{book_title}</title>
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #2c2c2c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}}
|
||||
.header {{
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}}
|
||||
.book-info {{
|
||||
flex: 1;
|
||||
}}
|
||||
.book-title {{
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.book-author {{
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}}
|
||||
.header-buttons {{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}}
|
||||
.header-button {{
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.header-button:hover {{
|
||||
background-color: #555;
|
||||
}}
|
||||
.page-container {{
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}}
|
||||
.page-image {{
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}}
|
||||
.footer {{
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}}
|
||||
.nav-button {{
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.nav-button:hover {{
|
||||
background-color: #555;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="book-info">
|
||||
<div class="book-title">{book_title}</div>
|
||||
<div class="book-author">{book_author}</div>
|
||||
</div>
|
||||
<div class="header-buttons">
|
||||
<button class="header-button" id="btn-library">Library</button>
|
||||
<button class="header-button" id="btn-toc">Contents</button>
|
||||
<button class="header-button" id="btn-settings">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-container">
|
||||
<img src="data:image/png;base64,{page_image_data}" alt="Page" class="page-image">
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="nav-button" id="btn-prev">← Previous</button>
|
||||
<div id="page-info"></div>
|
||||
<button class="nav-button" id="btn-next">Next →</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
|
||||
|
||||
def generate_settings_overlay(
|
||||
font_scale: float = 1.0,
|
||||
line_spacing: int = 5,
|
||||
inter_block_spacing: int = 15,
|
||||
word_spacing: int = 0,
|
||||
font_family: str = "Default",
|
||||
page_size: tuple = (800, 1200)
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML for the settings overlay with current values.
|
||||
|
||||
Uses simple paragraphs with links, similar to TOC overlay,
|
||||
since pyWebLayout doesn't support HTML tables.
|
||||
|
||||
Args:
|
||||
font_scale: Current font scale (e.g., 1.0 = 100%, 1.2 = 120%)
|
||||
line_spacing: Current line spacing in pixels
|
||||
inter_block_spacing: Current inter-block spacing in pixels
|
||||
word_spacing: Current word spacing in pixels
|
||||
font_family: Current font family ("Default", "SERIF", "SANS", "MONOSPACE")
|
||||
page_size: Page dimensions (width, height) for sizing the overlay
|
||||
|
||||
Returns:
|
||||
HTML string for settings overlay with clickable controls
|
||||
"""
|
||||
# Format current values for display
|
||||
font_percent = int(font_scale * 100)
|
||||
|
||||
# Map font family names to display names
|
||||
font_display_names = {
|
||||
"Default": "Document Default",
|
||||
"SERIF": "Serif",
|
||||
"SANS": "Sans-Serif",
|
||||
"MONOSPACE": "Monospace"
|
||||
}
|
||||
font_family_display = font_display_names.get(font_family, font_family)
|
||||
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
|
||||
|
||||
<h1 style="color: #000; margin: 0 0 8px 0; font-size: 24px; text-align: center; font-weight: bold;">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||
Adjust reading preferences
|
||||
</p>
|
||||
|
||||
<div style="margin: 15px 0;">
|
||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #6f42c1;">
|
||||
<b>Font Family: {font_family_display}</b>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_family_default" style="text-decoration: none; color: #000; display: block; padding: 12px;">Document Default</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_family_serif" style="text-decoration: none; color: #000; display: block; padding: 12px;">Serif</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_family_sans" style="text-decoration: none; color: #000; display: block; padding: 12px;">Sans-Serif</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_family_monospace" style="text-decoration: none; color: #000; display: block; padding: 12px;">Monospace</a>
|
||||
</p>
|
||||
|
||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
|
||||
<b>Font Size: {font_percent}%</b>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||
</p>
|
||||
|
||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
|
||||
<b>Line Spacing: {line_spacing}px</b>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||
</p>
|
||||
|
||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
|
||||
<b>Paragraph Spacing: {inter_block_spacing}px</b>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||
</p>
|
||||
|
||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
|
||||
<b>Word Spacing: {word_spacing}px</b>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||
</p>
|
||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0;">
|
||||
<p style="margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
||||
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px; display: block; padding: 15px;">◄ Back to Library</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
|
||||
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
|
||||
Changes apply in real-time • Tap outside to close
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
|
||||
|
||||
def generate_toc_overlay(
|
||||
chapters: List[Dict],
|
||||
page_size: tuple = (800, 1200),
|
||||
toc_page: int = 0,
|
||||
toc_items_per_page: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML for the table of contents overlay.
|
||||
|
||||
Args:
|
||||
chapters: List of chapter dictionaries with keys:
|
||||
- index: Chapter index
|
||||
- title: Chapter title
|
||||
page_size: Page dimensions (width, height) for sizing the overlay
|
||||
toc_page: Current page number (0-indexed)
|
||||
toc_items_per_page: Number of items to show per page
|
||||
|
||||
Returns:
|
||||
HTML string for TOC overlay (60% popup with transparent background)
|
||||
"""
|
||||
# Calculate pagination
|
||||
toc_total_pages = (len(chapters) + toc_items_per_page - 1) // toc_items_per_page if chapters else 1
|
||||
toc_start = toc_page * toc_items_per_page
|
||||
toc_end = min(toc_start + toc_items_per_page, len(chapters))
|
||||
toc_paginated = chapters[toc_start:toc_end]
|
||||
|
||||
# Build chapter list items with clickable links for pyWebLayout query
|
||||
chapter_items = []
|
||||
for i, chapter in enumerate(toc_paginated):
|
||||
title = chapter["title"]
|
||||
|
||||
# Use original chapter number (not the paginated index)
|
||||
chapter_num = toc_start + i + 1
|
||||
|
||||
# Wrap each row in a paragraph with an inline link
|
||||
# For very short titles (I, II), pad the link text to ensure it's clickable
|
||||
link_text = f'{chapter_num}. {title}'
|
||||
if len(title) <= 2:
|
||||
# Add extra padding spaces inside the link to make it easier to click
|
||||
link_text = f'{chapter_num}. {title} ' # Extra spaces for padding
|
||||
|
||||
chapter_items.append(
|
||||
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||
f'border-left: 3px solid #000;">'
|
||||
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
|
||||
f'{link_text}</a></p>'
|
||||
)
|
||||
|
||||
# Generate pagination controls
|
||||
toc_pagination = ""
|
||||
if toc_total_pages > 1:
|
||||
prev_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page == 0 else ''
|
||||
next_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page >= toc_total_pages - 1 else ''
|
||||
|
||||
toc_pagination = f'''
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
|
||||
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
|
||||
← Prev
|
||||
</a>
|
||||
<span style="color: #666; font-size: 13px;">
|
||||
Page {toc_page + 1} of {toc_total_pages}
|
||||
</span>
|
||||
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Render simple white panel - compositing will be done by OverlayManager
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Table of Contents</title>
|
||||
</head>
|
||||
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
|
||||
|
||||
<h1 style="color: #000; margin: 0 0 8px 0; font-size: 24px; text-align: center; font-weight: bold;">
|
||||
Table of Contents
|
||||
</h1>
|
||||
|
||||
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||
{len(chapters)} chapters
|
||||
</p>
|
||||
|
||||
<div style="min-height: 400px;">
|
||||
{"".join(chapter_items)}
|
||||
</div>
|
||||
|
||||
{toc_pagination}
|
||||
|
||||
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
|
||||
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
|
||||
Tap a chapter to navigate • Tap outside to close
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
|
||||
|
||||
def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
|
||||
"""
|
||||
Generate HTML for the bookmarks overlay.
|
||||
|
||||
Args:
|
||||
bookmarks: List of bookmark dictionaries with keys:
|
||||
- name: Bookmark name
|
||||
- position: Position info
|
||||
|
||||
Returns:
|
||||
HTML string for bookmarks overlay
|
||||
"""
|
||||
bookmark_rows = []
|
||||
for bookmark in bookmarks:
|
||||
bookmark_rows.append(f'''
|
||||
<tr class="bookmark-row" data-bookmark-name="{bookmark['name']}">
|
||||
<td class="bookmark-cell">
|
||||
<div class="bookmark-name">{bookmark['name']}</div>
|
||||
<div class="bookmark-position">{bookmark.get('position', '')}</div>
|
||||
</td>
|
||||
<td class="bookmark-actions">
|
||||
<button class="action-button delete-button" data-bookmark="{bookmark['name']}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
''')
|
||||
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookmarks</title>
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}}
|
||||
.overlay-panel {{
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
padding: 20px;
|
||||
min-width: 500px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
.overlay-header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}}
|
||||
.overlay-title {{
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.close-button {{
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.close-button:hover {{
|
||||
background-color: #c82333;
|
||||
}}
|
||||
.bookmarks-container {{
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}}
|
||||
.bookmarks-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}}
|
||||
.bookmark-row {{
|
||||
cursor: pointer;
|
||||
}}
|
||||
.bookmark-row:hover {{
|
||||
background-color: #f0f0f0;
|
||||
}}
|
||||
.bookmark-cell {{
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}}
|
||||
.bookmark-name {{
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}}
|
||||
.bookmark-position {{
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}}
|
||||
.bookmark-actions {{
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}}
|
||||
.action-button {{
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.action-button:hover {{
|
||||
background-color: #c82333;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-panel">
|
||||
<div class="overlay-header">
|
||||
<span class="overlay-title">Bookmarks</span>
|
||||
<button class="close-button" id="btn-close">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="bookmarks-container">
|
||||
<table class="bookmarks-table">
|
||||
{"".join(bookmark_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
|
||||
|
||||
def generate_navigation_overlay(
|
||||
chapters: List[Dict],
|
||||
bookmarks: List[Dict],
|
||||
active_tab: str = "contents",
|
||||
page_size: tuple = (800, 1200),
|
||||
toc_page: int = 0,
|
||||
toc_items_per_page: int = 10,
|
||||
bookmarks_page: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML for the unified navigation overlay with Contents and Bookmarks tabs.
|
||||
|
||||
This combines TOC and Bookmarks into a single overlay with tab switching and pagination.
|
||||
Tabs are clickable links that switch between contents (tab:contents) and bookmarks (tab:bookmarks).
|
||||
Pagination buttons (page:next, page:prev) allow navigating through large lists.
|
||||
|
||||
Args:
|
||||
chapters: List of chapter dictionaries with keys:
|
||||
- index: Chapter index
|
||||
- title: Chapter title
|
||||
bookmarks: List of bookmark dictionaries with keys:
|
||||
- name: Bookmark name
|
||||
- position: Position info (optional)
|
||||
active_tab: Which tab to show ("contents" or "bookmarks")
|
||||
page_size: Page dimensions (width, height) for sizing the overlay
|
||||
toc_page: Current page number for TOC (0-indexed)
|
||||
toc_items_per_page: Number of items to show per page
|
||||
bookmarks_page: Current page number for bookmarks (0-indexed)
|
||||
|
||||
Returns:
|
||||
HTML string for navigation overlay with tab switching and pagination
|
||||
"""
|
||||
# Calculate pagination for chapters
|
||||
toc_total_pages = (len(chapters) + toc_items_per_page - 1) // toc_items_per_page if chapters else 1
|
||||
toc_start = toc_page * toc_items_per_page
|
||||
toc_end = min(toc_start + toc_items_per_page, len(chapters))
|
||||
toc_paginated = chapters[toc_start:toc_end]
|
||||
|
||||
# Build chapter list items with clickable links
|
||||
chapter_items = []
|
||||
for i, chapter in enumerate(toc_paginated):
|
||||
title = chapter["title"]
|
||||
# Use original chapter number (not the paginated index)
|
||||
chapter_num = toc_start + i + 1
|
||||
link_text = f'{chapter_num}. {title}'
|
||||
if len(title) <= 2:
|
||||
link_text = f'{chapter_num}. {title} ' # Extra spaces for padding
|
||||
|
||||
chapter_items.append(
|
||||
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
||||
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
||||
f'{link_text}</a></p>'
|
||||
)
|
||||
|
||||
# Calculate pagination for bookmarks
|
||||
bookmarks_total_pages = (len(bookmarks) + toc_items_per_page - 1) // toc_items_per_page if bookmarks else 1
|
||||
bookmarks_start = bookmarks_page * toc_items_per_page
|
||||
bookmarks_end = min(bookmarks_start + toc_items_per_page, len(bookmarks))
|
||||
bookmarks_paginated = bookmarks[bookmarks_start:bookmarks_end]
|
||||
|
||||
# Build bookmark list items with clickable links
|
||||
bookmark_items = []
|
||||
for bookmark in bookmarks_paginated:
|
||||
name = bookmark['name']
|
||||
position_text = bookmark.get('position', 'Saved position')
|
||||
|
||||
bookmark_items.append(
|
||||
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
||||
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
||||
f'<span style="font-weight: bold; display: block;">{name}</span>'
|
||||
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
|
||||
f'</a></p>'
|
||||
)
|
||||
|
||||
# Determine which content to show
|
||||
contents_display = "block" if active_tab == "contents" else "none"
|
||||
bookmarks_display = "block" if active_tab == "bookmarks" else "none"
|
||||
|
||||
# Style active tab
|
||||
contents_tab_style = "background-color: #000; color: #fff;" if active_tab == "contents" else "background-color: #f0f0f0; color: #000;"
|
||||
bookmarks_tab_style = "background-color: #000; color: #fff;" if active_tab == "bookmarks" else "background-color: #f0f0f0; color: #000;"
|
||||
|
||||
chapters_html = ''.join(chapter_items) if chapter_items else '<p style="padding: 20px; text-align: center; color: #999;">No chapters available</p>'
|
||||
bookmarks_html = ''.join(bookmark_items) if bookmark_items else '<p style="padding: 20px; text-align: center; color: #999;">No bookmarks yet</p>'
|
||||
|
||||
# Generate pagination controls for TOC
|
||||
toc_pagination = ""
|
||||
if toc_total_pages > 1:
|
||||
prev_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page == 0 else ''
|
||||
next_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page >= toc_total_pages - 1 else ''
|
||||
|
||||
toc_pagination = f'''
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
|
||||
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
|
||||
← Prev
|
||||
</a>
|
||||
<span style="color: #666; font-size: 13px;">
|
||||
Page {toc_page + 1} of {toc_total_pages}
|
||||
</span>
|
||||
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Generate pagination controls for Bookmarks
|
||||
bookmarks_pagination = ""
|
||||
if bookmarks_total_pages > 1:
|
||||
prev_disabled = 'opacity: 0.3; pointer-events: none;' if bookmarks_page == 0 else ''
|
||||
next_disabled = 'opacity: 0.3; pointer-events: none;' if bookmarks_page >= bookmarks_total_pages - 1 else ''
|
||||
|
||||
bookmarks_pagination = f'''
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
|
||||
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
|
||||
← Prev
|
||||
</a>
|
||||
<span style="color: #666; font-size: 13px;">
|
||||
Page {bookmarks_page + 1} of {bookmarks_total_pages}
|
||||
</span>
|
||||
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Navigation</title>
|
||||
</head>
|
||||
<body style="background-color: white; margin: 0; padding: 0; font-family: Arial, sans-serif;">
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div style="display: flex; border-bottom: 2px solid #ccc; background-color: #f8f8f8;">
|
||||
<a href="tab:contents"
|
||||
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
|
||||
text-decoration: none; border-right: 1px solid #ccc; {contents_tab_style}">
|
||||
Contents
|
||||
</a>
|
||||
<a href="tab:bookmarks"
|
||||
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
|
||||
text-decoration: none; {bookmarks_tab_style}">
|
||||
Bookmarks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contents Tab Content -->
|
||||
<div id="contents-tab" style="padding: 25px; display: {contents_display};">
|
||||
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
|
||||
Table of Contents
|
||||
</h2>
|
||||
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||
{len(chapters)} chapters
|
||||
</p>
|
||||
<div style="min-height: 400px;">
|
||||
{chapters_html}
|
||||
</div>
|
||||
{toc_pagination}
|
||||
</div>
|
||||
|
||||
<!-- Bookmarks Tab Content -->
|
||||
<div id="bookmarks-tab" style="padding: 25px; display: {bookmarks_display};">
|
||||
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
|
||||
Bookmarks
|
||||
</h2>
|
||||
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||
{len(bookmarks)} saved
|
||||
</p>
|
||||
<div style="min-height: 400px;">
|
||||
{bookmarks_html}
|
||||
</div>
|
||||
{bookmarks_pagination}
|
||||
</div>
|
||||
|
||||
<!-- Close Button (bottom right) -->
|
||||
<div style="position: fixed; bottom: 20px; right: 20px;">
|
||||
<a href="action:close"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #dc3545;
|
||||
color: white; text-decoration: none; border-radius: 4px; font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
Library manager for browsing and selecting books.
|
||||
|
||||
Handles:
|
||||
- Scanning directories for EPUB files
|
||||
- Extracting and caching book metadata and covers
|
||||
- Rendering interactive library view using pyWebLayout
|
||||
- Processing tap/click events to select books
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from PIL import Image, ImageDraw
|
||||
import tempfile
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract.block import Table
|
||||
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
|
||||
from .book_utils import scan_book_directory, extract_book_metadata
|
||||
from .state import LibraryState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""
|
||||
Manages the book library view and interactions.
|
||||
|
||||
Features:
|
||||
- Scan EPUB directories
|
||||
- Cache book metadata and covers
|
||||
- Render interactive library table
|
||||
- Handle tap events to select books
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library_path: str,
|
||||
cache_dir: Optional[str] = None,
|
||||
page_size: Tuple[int, int] = (800, 1200),
|
||||
books_per_page: int = 6
|
||||
):
|
||||
"""
|
||||
Initialize library manager.
|
||||
|
||||
Args:
|
||||
library_path: Path to directory containing EPUB files
|
||||
cache_dir: Optional cache directory for covers. If None, uses default.
|
||||
page_size: Page size for library view rendering
|
||||
books_per_page: Number of books to display per page (must be even for 2-column layout, default: 6)
|
||||
"""
|
||||
self.library_path = Path(library_path)
|
||||
self.page_size = page_size
|
||||
self.books_per_page = books_per_page if books_per_page % 2 == 0 else books_per_page + 1
|
||||
|
||||
# Set cache directory
|
||||
if cache_dir:
|
||||
self.cache_dir = Path(cache_dir)
|
||||
else:
|
||||
self.cache_dir = self._get_default_cache_dir()
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.covers_dir = self.cache_dir / 'covers'
|
||||
self.covers_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Current library state
|
||||
self.books: List[Dict] = []
|
||||
self.library_table: Optional[Table] = None
|
||||
self.rendered_page: Optional[Page] = None
|
||||
self.temp_cover_files: List[str] = [] # Track temp files for cleanup
|
||||
self.row_bounds: List[Tuple[int, int, int, int]] = [] # Bounding boxes for rows (x, y, w, h)
|
||||
self.table_renderer: Optional[TableRenderer] = None # Store renderer for bounds info
|
||||
self.current_page: int = 0 # Current page index for pagination
|
||||
|
||||
@staticmethod
|
||||
def _get_default_cache_dir() -> Path:
|
||||
"""Get default cache directory based on platform"""
|
||||
if os.name == 'nt': # Windows
|
||||
config_dir = Path(os.environ.get('APPDATA', '~/.config'))
|
||||
else: # Linux/Mac
|
||||
config_dir = Path.home() / '.config'
|
||||
|
||||
return config_dir / 'dreader'
|
||||
|
||||
def scan_library(self, force_refresh: bool = False) -> List[Dict]:
|
||||
"""
|
||||
Scan library directory for EPUB files and extract metadata.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, re-scan even if cache exists
|
||||
|
||||
Returns:
|
||||
List of book dictionaries with metadata
|
||||
"""
|
||||
start_time = time.time()
|
||||
logger.info(f"[LIBRARY] Scanning library: {self.library_path}")
|
||||
print(f"Scanning library: {self.library_path}")
|
||||
|
||||
if not self.library_path.exists():
|
||||
logger.error(f"Library path does not exist: {self.library_path}")
|
||||
print(f"Library path does not exist: {self.library_path}")
|
||||
return []
|
||||
|
||||
# Scan directory
|
||||
scan_start = time.time()
|
||||
self.books = scan_book_directory(self.library_path)
|
||||
scan_elapsed = time.time() - scan_start
|
||||
logger.info(f"[LIBRARY] Directory scan completed in {scan_elapsed:.2f}s - found {len(self.books)} books")
|
||||
|
||||
# Cache covers to disk if not already cached
|
||||
cache_start = time.time()
|
||||
for i, book in enumerate(self.books, 1):
|
||||
book_start = time.time()
|
||||
self._cache_book_cover(book)
|
||||
book_elapsed = time.time() - book_start
|
||||
if book_elapsed > 0.1: # Only log if caching took significant time
|
||||
logger.info(f"[LIBRARY] Cached cover {i}/{len(self.books)}: {book['title']} ({book_elapsed:.2f}s)")
|
||||
cache_elapsed = time.time() - cache_start
|
||||
logger.info(f"[LIBRARY] Cover caching completed in {cache_elapsed:.2f}s")
|
||||
|
||||
total_elapsed = time.time() - start_time
|
||||
logger.info(f"[LIBRARY] Library scan complete: {len(self.books)} books in {total_elapsed:.2f}s")
|
||||
print(f"Found {len(self.books)} books in library")
|
||||
return self.books
|
||||
|
||||
def _cache_book_cover(self, book: Dict) -> Optional[str]:
|
||||
"""
|
||||
Cache book cover image to disk.
|
||||
|
||||
Args:
|
||||
book: Book dictionary with cover_data (base64) or path
|
||||
|
||||
Returns:
|
||||
Path to cached cover file, or None if no cover
|
||||
"""
|
||||
if not book.get('cover_data'):
|
||||
return None
|
||||
|
||||
# Generate cache filename from book path
|
||||
book_path = Path(book['path'])
|
||||
cover_filename = f"{book_path.stem}_cover.png"
|
||||
cover_path = self.covers_dir / cover_filename
|
||||
|
||||
# Skip if already cached
|
||||
if cover_path.exists():
|
||||
book['cover_path'] = str(cover_path)
|
||||
return str(cover_path)
|
||||
|
||||
try:
|
||||
# Decode base64 and save to cache
|
||||
img_data = base64.b64decode(book['cover_data'])
|
||||
img = Image.open(BytesIO(img_data))
|
||||
img.save(cover_path, 'PNG')
|
||||
|
||||
book['cover_path'] = str(cover_path)
|
||||
print(f"Cached cover: {cover_filename}")
|
||||
return str(cover_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error caching cover for {book['title']}: {e}")
|
||||
return None
|
||||
|
||||
def create_library_table(self, books: Optional[List[Dict]] = None, page: Optional[int] = None) -> Table:
|
||||
"""
|
||||
Create interactive library table with book covers and info in 2-column grid.
|
||||
|
||||
Args:
|
||||
books: List of books to display. If None, uses self.books
|
||||
page: Page number to display (0-indexed). If None, uses self.current_page
|
||||
|
||||
Returns:
|
||||
Table object ready for rendering
|
||||
"""
|
||||
if books is None:
|
||||
books = self.books
|
||||
|
||||
if page is None:
|
||||
page = self.current_page
|
||||
|
||||
if not books:
|
||||
print("No books to display in library")
|
||||
books = []
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (len(books) + self.books_per_page - 1) // self.books_per_page
|
||||
start_idx = page * self.books_per_page
|
||||
end_idx = min(start_idx + self.books_per_page, len(books))
|
||||
page_books = books[start_idx:end_idx]
|
||||
|
||||
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
|
||||
|
||||
# Create table with caption showing page info
|
||||
caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library"
|
||||
table = Table(caption=caption_text, style=Font(font_size=18, weight="bold"))
|
||||
|
||||
# Add books in 2-column grid (each pair of books gets 2 rows: covers then details)
|
||||
for i in range(0, len(page_books), 2):
|
||||
# Row 1: Covers for this pair
|
||||
cover_row = table.create_row("body")
|
||||
|
||||
# Add first book's cover (left column)
|
||||
self._add_book_cover(cover_row, page_books[i])
|
||||
|
||||
# Add second book's cover (right column) if it exists
|
||||
if i + 1 < len(page_books):
|
||||
self._add_book_cover(cover_row, page_books[i + 1])
|
||||
else:
|
||||
# Add empty cell if odd number of books
|
||||
cover_row.create_cell()
|
||||
|
||||
# Row 2: Details for this pair
|
||||
details_row = table.create_row("body")
|
||||
|
||||
# Add first book's details (left column)
|
||||
self._add_book_details(details_row, page_books[i])
|
||||
|
||||
# Add second book's details (right column) if it exists
|
||||
if i + 1 < len(page_books):
|
||||
self._add_book_details(details_row, page_books[i + 1])
|
||||
else:
|
||||
# Add empty cell if odd number of books
|
||||
details_row.create_cell()
|
||||
|
||||
self.library_table = table
|
||||
return table
|
||||
|
||||
def _add_book_cover(self, row, book: Dict):
|
||||
"""
|
||||
Add a book cover to a table row.
|
||||
|
||||
Args:
|
||||
row: Table row to add cover to
|
||||
book: Book dictionary with metadata
|
||||
"""
|
||||
cover_cell = row.create_cell()
|
||||
|
||||
cover_path = book.get('cover_path')
|
||||
book_path = book['path']
|
||||
|
||||
# Create callback that returns book path
|
||||
callback = lambda point, path=book_path: path
|
||||
|
||||
# Add cover image
|
||||
if cover_path and Path(cover_path).exists():
|
||||
# Use cached cover with callback
|
||||
img = InteractiveImage.create_and_add_to(
|
||||
cover_cell,
|
||||
source=cover_path,
|
||||
alt_text=book['title'],
|
||||
callback=callback
|
||||
)
|
||||
elif book.get('cover_data'):
|
||||
# Decode base64 and save to temp file for InteractiveImage
|
||||
try:
|
||||
img_data = base64.b64decode(book['cover_data'])
|
||||
img = Image.open(BytesIO(img_data))
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
img.save(tmp.name, 'PNG')
|
||||
temp_path = tmp.name
|
||||
self.temp_cover_files.append(temp_path)
|
||||
|
||||
img = InteractiveImage.create_and_add_to(
|
||||
cover_cell,
|
||||
source=temp_path,
|
||||
alt_text=book['title'],
|
||||
callback=callback
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error creating cover image for {book['title']}: {e}")
|
||||
self._add_no_cover_text(cover_cell)
|
||||
else:
|
||||
# No cover available
|
||||
self._add_no_cover_text(cover_cell)
|
||||
|
||||
def _add_book_details(self, row, book: Dict):
|
||||
"""
|
||||
Add book details (title, author, filename) to a table row.
|
||||
|
||||
Args:
|
||||
row: Table row to add details to
|
||||
book: Book dictionary with metadata
|
||||
"""
|
||||
details_cell = row.create_cell()
|
||||
|
||||
# Title paragraph
|
||||
title_para = details_cell.create_paragraph()
|
||||
for word in book['title'].split():
|
||||
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
||||
|
||||
# Author paragraph
|
||||
author_para = details_cell.create_paragraph()
|
||||
for word in book.get('author', 'Unknown').split():
|
||||
author_para.add_word(Word(word, Font(font_size=12)))
|
||||
|
||||
# Filename paragraph (small, gray)
|
||||
filename_para = details_cell.create_paragraph()
|
||||
filename_para.add_word(Word(
|
||||
Path(book['path']).name,
|
||||
Font(font_size=10, colour=(150, 150, 150))
|
||||
))
|
||||
|
||||
def _add_no_cover_text(self, cell):
|
||||
"""Add placeholder text when no cover is available"""
|
||||
para = cell.create_paragraph()
|
||||
para.add_word(Word("[No", Font(font_size=10, colour=(128, 128, 128))))
|
||||
para.add_word(Word("cover]", Font(font_size=10, colour=(128, 128, 128))))
|
||||
|
||||
def render_library(self, table: Optional[Table] = None) -> Image.Image:
|
||||
"""
|
||||
Render the library table to an image.
|
||||
|
||||
Args:
|
||||
table: Table to render. If None, uses self.library_table
|
||||
|
||||
Returns:
|
||||
PIL Image of the rendered library
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
if table is None:
|
||||
if self.library_table is None:
|
||||
print("No table to render, creating one first...")
|
||||
logger.info("[LIBRARY] Creating library table...")
|
||||
self.create_library_table()
|
||||
table = self.library_table
|
||||
|
||||
print("Rendering library table...")
|
||||
logger.info("[LIBRARY] Rendering library table...")
|
||||
|
||||
# Create page
|
||||
page_start = time.time()
|
||||
page_style = PageStyle(
|
||||
border_width=0,
|
||||
padding=(30, 30, 30, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=self.page_size, style=page_style)
|
||||
canvas = page.render()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
page_elapsed = time.time() - page_start
|
||||
logger.info(f"[LIBRARY] Page creation took {page_elapsed:.2f}s")
|
||||
|
||||
# Table style
|
||||
table_style = TableStyle(
|
||||
border_width=1,
|
||||
border_color=(200, 200, 200),
|
||||
cell_padding=(10, 15, 10, 15),
|
||||
header_bg_color=(240, 240, 240),
|
||||
cell_bg_color=(255, 255, 255),
|
||||
alternate_row_color=(250, 250, 250)
|
||||
)
|
||||
|
||||
# Position table
|
||||
table_origin = (page_style.padding[3], page_style.padding[0])
|
||||
table_width = page.size[0] - page_style.padding[1] - page_style.padding[3]
|
||||
|
||||
# Render table with canvas support for images
|
||||
render_start = time.time()
|
||||
logger.info("[LIBRARY] Starting table render (this may load fonts)...")
|
||||
self.table_renderer = TableRenderer(
|
||||
table,
|
||||
table_origin,
|
||||
table_width,
|
||||
draw,
|
||||
table_style,
|
||||
canvas # Pass canvas to enable image rendering
|
||||
)
|
||||
self.table_renderer.render()
|
||||
render_elapsed = time.time() - render_start
|
||||
logger.info(f"[LIBRARY] Table rendering took {render_elapsed:.2f}s")
|
||||
|
||||
# Store rendered page for query support
|
||||
self.rendered_page = page
|
||||
|
||||
total_elapsed = time.time() - start_time
|
||||
logger.info(f"[LIBRARY] Total render time: {total_elapsed:.2f}s")
|
||||
|
||||
return canvas
|
||||
|
||||
def handle_library_tap(self, x: int, y: int) -> Optional[str]:
|
||||
"""
|
||||
Handle tap event on library view with 2-column grid.
|
||||
|
||||
The layout has alternating rows: cover rows and detail rows.
|
||||
Each pair of rows (cover + detail) represents one pair of books (2 books).
|
||||
Tapping on either the cover row or detail row selects the corresponding book.
|
||||
|
||||
Args:
|
||||
x: X coordinate of tap
|
||||
y: Y coordinate of tap
|
||||
|
||||
Returns:
|
||||
Path to selected book, or None if no book tapped
|
||||
"""
|
||||
if not self.library_table or not self.table_renderer:
|
||||
print("No library table available")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get paginated books for current page
|
||||
start_idx = self.current_page * self.books_per_page
|
||||
end_idx = min(start_idx + self.books_per_page, len(self.books))
|
||||
page_books = self.books[start_idx:end_idx]
|
||||
|
||||
# Build a mapping of row sections in order
|
||||
all_rows = list(self.library_table.all_rows())
|
||||
|
||||
# Find which row was tapped by checking row renderers
|
||||
for row_idx, row_renderer in enumerate(self.table_renderer._row_renderers):
|
||||
# Get the row renderer's bounds
|
||||
row_x, row_y = row_renderer._origin
|
||||
row_w, row_h = row_renderer._size
|
||||
|
||||
# Check if tap is within this row's bounds
|
||||
if (row_x <= x <= row_x + row_w and
|
||||
row_y <= y <= row_y + row_h):
|
||||
|
||||
# Get the section and row for this renderer index
|
||||
if row_idx < len(all_rows):
|
||||
section, row = all_rows[row_idx]
|
||||
|
||||
# Only handle body rows
|
||||
if section == "body":
|
||||
# Find which body row this is (0-indexed)
|
||||
body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body")
|
||||
|
||||
# Each pair of books uses 2 rows (cover row + detail row)
|
||||
# Determine which book pair this row belongs to
|
||||
book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...)
|
||||
is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details
|
||||
|
||||
# Check cell renderers in this row
|
||||
if hasattr(row_renderer, '_cell_renderers') and len(row_renderer._cell_renderers) >= 1:
|
||||
# Check left cell (first book in pair)
|
||||
left_cell = row_renderer._cell_renderers[0]
|
||||
left_x, left_y = left_cell._origin
|
||||
left_w, left_h = left_cell._size
|
||||
|
||||
if (left_x <= x <= left_x + left_w and
|
||||
left_y <= y <= left_y + left_h):
|
||||
# Left column (first book in pair)
|
||||
book_index = book_pair_index * 2
|
||||
if book_index < len(page_books):
|
||||
book_path = page_books[book_index]['path']
|
||||
row_type = "cover" if is_cover_row else "detail"
|
||||
print(f"Book selected (pair {book_pair_index}, left {row_type}): {book_path}")
|
||||
return book_path
|
||||
|
||||
# Check right cell (second book in pair) if it exists
|
||||
if len(row_renderer._cell_renderers) >= 2:
|
||||
right_cell = row_renderer._cell_renderers[1]
|
||||
right_x, right_y = right_cell._origin
|
||||
right_w, right_h = right_cell._size
|
||||
|
||||
if (right_x <= x <= right_x + right_w and
|
||||
right_y <= y <= right_y + right_h):
|
||||
# Right column (second book in pair)
|
||||
book_index = book_pair_index * 2 + 1
|
||||
if book_index < len(page_books):
|
||||
book_path = page_books[book_index]['path']
|
||||
row_type = "cover" if is_cover_row else "detail"
|
||||
print(f"Book selected (pair {book_pair_index}, right {row_type}): {book_path}")
|
||||
return book_path
|
||||
|
||||
print(f"No book tapped at ({x}, {y})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error handling library tap: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def get_book_at_index(self, index: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get book by index in library.
|
||||
|
||||
Args:
|
||||
index: Book index
|
||||
|
||||
Returns:
|
||||
Book dictionary or None
|
||||
"""
|
||||
if 0 <= index < len(self.books):
|
||||
return self.books[index]
|
||||
return None
|
||||
|
||||
def next_page(self) -> bool:
|
||||
"""
|
||||
Navigate to next page of library.
|
||||
|
||||
Returns:
|
||||
True if page changed, False if already on last page
|
||||
"""
|
||||
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
||||
if self.current_page < total_pages - 1:
|
||||
self.current_page += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def previous_page(self) -> bool:
|
||||
"""
|
||||
Navigate to previous page of library.
|
||||
|
||||
Returns:
|
||||
True if page changed, False if already on first page
|
||||
"""
|
||||
if self.current_page > 0:
|
||||
self.current_page -= 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_page(self, page: int) -> bool:
|
||||
"""
|
||||
Set current page.
|
||||
|
||||
Args:
|
||||
page: Page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
True if page changed, False if invalid page
|
||||
"""
|
||||
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
||||
if 0 <= page < total_pages:
|
||||
self.current_page = page
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_total_pages(self) -> int:
|
||||
"""
|
||||
Get total number of pages.
|
||||
|
||||
Returns:
|
||||
Total number of pages
|
||||
"""
|
||||
return (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
||||
|
||||
def get_library_state(self) -> LibraryState:
|
||||
"""
|
||||
Get current library state for persistence.
|
||||
|
||||
Returns:
|
||||
LibraryState object
|
||||
"""
|
||||
return LibraryState(
|
||||
books_path=str(self.library_path),
|
||||
last_selected_index=0, # TODO: Track last selection
|
||||
scan_cache=[
|
||||
{
|
||||
'path': book['path'],
|
||||
'title': book['title'],
|
||||
'author': book.get('author', 'Unknown'),
|
||||
'cover_cached': bool(book.get('cover_path'))
|
||||
}
|
||||
for book in self.books
|
||||
]
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up temporary files"""
|
||||
for temp_file in self.temp_cover_files:
|
||||
try:
|
||||
if os.path.exists(temp_file):
|
||||
os.unlink(temp_file)
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up temp file {temp_file}: {e}")
|
||||
self.temp_cover_files.clear()
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor to ensure cleanup"""
|
||||
self.cleanup()
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Main application controller for DReader e-reader application.
|
||||
|
||||
This module provides the DReaderApplication class which orchestrates:
|
||||
- Library and reading mode transitions
|
||||
- State persistence and recovery
|
||||
- HAL integration for display and input
|
||||
- Event routing and handling
|
||||
|
||||
The application uses asyncio for non-blocking operations and integrates
|
||||
with a hardware abstraction layer (HAL) for platform independence.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
|
||||
from .library import LibraryManager
|
||||
from .application import EbookReader
|
||||
from .state import StateManager, EreaderMode, OverlayState, BookState
|
||||
from .gesture import TouchEvent, GestureType, ActionType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""
|
||||
Configuration for DReaderApplication.
|
||||
|
||||
Attributes:
|
||||
display_hal: Hardware abstraction layer for display/input
|
||||
library_path: Path to directory containing EPUB files
|
||||
page_size: Tuple of (width, height) for rendered pages
|
||||
bookmarks_dir: Directory for bookmark storage (default: ~/.config/dreader/bookmarks)
|
||||
highlights_dir: Directory for highlights storage (default: ~/.config/dreader/highlights)
|
||||
state_file: Path to state JSON file (default: ~/.config/dreader/state.json)
|
||||
auto_save_interval: Seconds between automatic state saves (default: 60)
|
||||
force_library_mode: If True, always start in library mode (default: False)
|
||||
log_level: Logging level (default: logging.INFO)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_hal,
|
||||
library_path: str,
|
||||
page_size: tuple[int, int] = (800, 1200),
|
||||
bookmarks_dir: Optional[str] = None,
|
||||
highlights_dir: Optional[str] = None,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save_interval: int = 60,
|
||||
force_library_mode: bool = False,
|
||||
log_level: int = logging.INFO
|
||||
):
|
||||
self.display_hal = display_hal
|
||||
self.library_path = library_path
|
||||
self.page_size = page_size
|
||||
self.force_library_mode = force_library_mode
|
||||
|
||||
# Set up default config paths
|
||||
config_dir = Path.home() / ".config" / "dreader"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.bookmarks_dir = bookmarks_dir or str(config_dir / "bookmarks")
|
||||
self.highlights_dir = highlights_dir or str(config_dir / "highlights")
|
||||
self.state_file = state_file or str(config_dir / "state.json")
|
||||
self.auto_save_interval = auto_save_interval
|
||||
self.log_level = log_level
|
||||
|
||||
|
||||
class DReaderApplication:
|
||||
"""
|
||||
Main application controller coordinating library and reading modes.
|
||||
|
||||
This class orchestrates all major components of the e-reader:
|
||||
- LibraryManager for book browsing
|
||||
- EbookReader for reading books
|
||||
- StateManager for persistence
|
||||
- DisplayHAL for hardware integration
|
||||
|
||||
Usage:
|
||||
config = AppConfig(
|
||||
display_hal=MyDisplayHAL(),
|
||||
library_path="/path/to/books"
|
||||
)
|
||||
|
||||
app = DReaderApplication(config)
|
||||
await app.start()
|
||||
|
||||
# In event loop:
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
await app.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(self, config: AppConfig):
|
||||
"""
|
||||
Initialize the application with configuration.
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=config.log_level)
|
||||
logger.info("Initializing DReaderApplication")
|
||||
|
||||
# State management
|
||||
self.state_manager = StateManager(
|
||||
state_file=config.state_file,
|
||||
auto_save_interval=config.auto_save_interval
|
||||
)
|
||||
self.state = self.state_manager.load_state()
|
||||
logger.info(f"Loaded state: mode={self.state.mode}, current_book={self.state.current_book}")
|
||||
|
||||
# Components (lazy-initialized)
|
||||
self.library: Optional[LibraryManager] = None
|
||||
self.reader: Optional[EbookReader] = None
|
||||
|
||||
# Display abstraction
|
||||
self.display_hal = config.display_hal
|
||||
self.current_image: Optional[Image.Image] = None
|
||||
|
||||
# Running state
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the application and display initial screen.
|
||||
|
||||
This method:
|
||||
1. Starts automatic state saving
|
||||
2. Restores previous mode or shows library
|
||||
3. Displays the initial screen
|
||||
"""
|
||||
logger.info("Starting DReaderApplication")
|
||||
self.running = True
|
||||
|
||||
# Start auto-save
|
||||
self.state_manager.start_auto_save()
|
||||
logger.info(f"Auto-save started (interval: {self.config.auto_save_interval}s)")
|
||||
|
||||
# Restore previous mode (or force library mode if configured)
|
||||
force_library = getattr(self.config, 'force_library_mode', False)
|
||||
|
||||
if force_library:
|
||||
logger.info("Force library mode enabled - starting in library")
|
||||
await self._enter_library_mode()
|
||||
elif self.state.mode == EreaderMode.READING and self.state.current_book:
|
||||
logger.info(f"Resuming reading mode: {self.state.current_book.path}")
|
||||
await self._enter_reading_mode(self.state.current_book.path)
|
||||
else:
|
||||
logger.info("Entering library mode")
|
||||
await self._enter_library_mode()
|
||||
|
||||
# Display initial screen
|
||||
await self._update_display()
|
||||
logger.info("Application started successfully")
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Gracefully shutdown the application.
|
||||
|
||||
This method:
|
||||
1. Saves current reading position
|
||||
2. Closes active components
|
||||
3. Stops auto-save and saves final state
|
||||
"""
|
||||
logger.info("Shutting down DReaderApplication")
|
||||
self.running = False
|
||||
|
||||
# Save current position if reading
|
||||
if self.reader and self.reader.is_loaded():
|
||||
logger.info("Saving auto-resume position")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
|
||||
# Clean up library
|
||||
if self.library:
|
||||
self.library.cleanup()
|
||||
|
||||
# Stop auto-save and save final state
|
||||
await self.state_manager.stop_auto_save(save_final=True)
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
async def handle_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Process touch event based on current mode.
|
||||
|
||||
Args:
|
||||
event: Touch event from HAL
|
||||
"""
|
||||
logger.info(f"[APP] Received touch event: {event.gesture.value} at ({event.x}, {event.y}), mode={self.state.mode.value}")
|
||||
|
||||
if self.state.mode == EreaderMode.LIBRARY:
|
||||
logger.info("[APP] Routing to library touch handler")
|
||||
await self._handle_library_touch(event)
|
||||
elif self.state.mode == EreaderMode.READING:
|
||||
logger.info("[APP] Routing to reading touch handler")
|
||||
await self._handle_reading_touch(event)
|
||||
|
||||
# Update display after handling
|
||||
await self._update_display()
|
||||
|
||||
async def _enter_library_mode(self):
|
||||
"""
|
||||
Switch to library browsing mode.
|
||||
|
||||
This method:
|
||||
1. Saves and closes reader if active
|
||||
2. Initializes library manager
|
||||
3. Renders library view
|
||||
4. Updates state
|
||||
"""
|
||||
logger.info("Entering library mode")
|
||||
|
||||
# Save and close reader if active
|
||||
if self.reader:
|
||||
if self.reader.is_loaded():
|
||||
logger.info("Saving reading position before closing")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
self.reader = None
|
||||
|
||||
# Initialize library if needed
|
||||
if not self.library:
|
||||
logger.info(f"Initializing library manager: {self.config.library_path}")
|
||||
self.library = LibraryManager(
|
||||
library_path=self.config.library_path,
|
||||
page_size=self.config.page_size,
|
||||
cache_dir=None # Uses default ~/.config/dreader
|
||||
)
|
||||
|
||||
# Scan for books (async operation)
|
||||
logger.info("Scanning library for books")
|
||||
books = self.library.scan_library()
|
||||
logger.info(f"Found {len(books)} books")
|
||||
|
||||
# Render library view
|
||||
logger.info("Rendering library view")
|
||||
self.current_image = self.library.render_library()
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_mode(EreaderMode.LIBRARY)
|
||||
logger.info("Library mode active")
|
||||
|
||||
async def _enter_reading_mode(self, book_path: str):
|
||||
"""
|
||||
Switch to reading mode.
|
||||
|
||||
Args:
|
||||
book_path: Path to EPUB file to open
|
||||
|
||||
This method:
|
||||
1. Initializes reader if needed
|
||||
2. Loads the book
|
||||
3. Applies saved settings
|
||||
4. Restores reading position
|
||||
5. Updates state
|
||||
6. Renders first/current page
|
||||
"""
|
||||
logger.info(f"Entering reading mode: {book_path}")
|
||||
|
||||
# Verify book exists
|
||||
if not Path(book_path).exists():
|
||||
logger.error(f"Book not found: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
# Initialize reader if needed
|
||||
if not self.reader:
|
||||
logger.info("Initializing ebook reader")
|
||||
self.reader = EbookReader(
|
||||
page_size=self.config.page_size,
|
||||
margin=40,
|
||||
background_color=(255, 255, 255),
|
||||
bookmarks_dir=self.config.bookmarks_dir,
|
||||
highlights_dir=self.config.highlights_dir
|
||||
)
|
||||
|
||||
# Load book
|
||||
logger.info(f"Loading EPUB: {book_path}")
|
||||
success = self.reader.load_epub(book_path)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Failed to load EPUB: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
logger.info(f"Loaded: {self.reader.book_title} by {self.reader.book_author}")
|
||||
|
||||
# Apply saved settings
|
||||
logger.info("Applying saved settings")
|
||||
settings_dict = self.state.settings.to_dict()
|
||||
self.reader.apply_settings(settings_dict)
|
||||
|
||||
# Restore position
|
||||
logger.info("Restoring reading position")
|
||||
position_loaded = self.reader.load_position("__auto_resume__")
|
||||
if position_loaded:
|
||||
pos_info = self.reader.get_position_info()
|
||||
logger.info(f"Resumed at position: {pos_info}")
|
||||
else:
|
||||
logger.info("No saved position, starting from beginning")
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_current_book(BookState(
|
||||
path=book_path,
|
||||
title=self.reader.book_title or "Unknown",
|
||||
author=self.reader.book_author or "Unknown"
|
||||
))
|
||||
self.state_manager.set_mode(EreaderMode.READING)
|
||||
|
||||
# Render current page
|
||||
logger.info("Rendering current page")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
logger.info("Reading mode active")
|
||||
|
||||
async def _handle_library_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in library mode.
|
||||
|
||||
Supports:
|
||||
- TAP: Select a book to read
|
||||
- SWIPE_LEFT: Next page
|
||||
- SWIPE_RIGHT: Previous page
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
if event.gesture == GestureType.TAP:
|
||||
logger.debug(f"Library tap at ({event.x}, {event.y})")
|
||||
|
||||
# Check if a book was selected
|
||||
book_path = self.library.handle_library_tap(event.x, event.y)
|
||||
|
||||
if book_path:
|
||||
logger.info(f"Book selected: {book_path}")
|
||||
await self._enter_reading_mode(book_path)
|
||||
else:
|
||||
logger.debug("Tap did not hit a book")
|
||||
|
||||
elif event.gesture == GestureType.SWIPE_LEFT:
|
||||
logger.debug("Library: swipe left (next page)")
|
||||
if self.library.next_page():
|
||||
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
|
||||
# Re-render library with new page
|
||||
self.library.create_library_table()
|
||||
self.current_image = self.library.render_library()
|
||||
else:
|
||||
logger.debug("Library: already on last page")
|
||||
|
||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||
logger.debug("Library: swipe right (previous page)")
|
||||
if self.library.previous_page():
|
||||
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
|
||||
# Re-render library with new page
|
||||
self.library.create_library_table()
|
||||
self.current_image = self.library.render_library()
|
||||
else:
|
||||
logger.debug("Library: already on first page")
|
||||
|
||||
async def _handle_reading_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in reading mode.
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
# Delegate to reader's gesture handler
|
||||
logger.info(f"[APP] Calling reader.handle_touch({event.gesture.value})")
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# response.action is already a string (ActionType enum value), not the enum itself
|
||||
logger.info(f"[APP] Reader response: action={response.action}, data={response.data}")
|
||||
|
||||
# Handle special actions
|
||||
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||
logger.info("[APP] → Returning to library")
|
||||
await self._enter_library_mode()
|
||||
|
||||
elif response.action == ActionType.PAGE_TURN:
|
||||
logger.info(f"[APP] → Page turned: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_OPENED:
|
||||
logger.info(f"[APP] → Overlay opened: {self.reader.get_overlay_state()}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_CLOSED:
|
||||
logger.info("[APP] → Overlay closed")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.SETTING_CHANGED:
|
||||
logger.info(f"[APP] → Setting changed: {response.data}")
|
||||
# Update state with new settings
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.CHAPTER_SELECTED:
|
||||
logger.info(f"[APP] → Chapter selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.BOOKMARK_SELECTED:
|
||||
logger.info(f"[APP] → Bookmark selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.NAVIGATE:
|
||||
logger.debug("Navigation action")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ZOOM:
|
||||
logger.info(f"Zoom action: {response.data}")
|
||||
# Font size changed
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ERROR:
|
||||
logger.error(f"Error: {response.data}")
|
||||
|
||||
async def _update_display(self):
|
||||
"""
|
||||
Update the display with current image.
|
||||
|
||||
This method sends the current image to the HAL for display.
|
||||
"""
|
||||
if self.current_image:
|
||||
logger.info(f"[DISPLAY] Updating display: {self.current_image.size} in {self.state.mode.value} mode")
|
||||
await self.display_hal.show_image(self.current_image)
|
||||
logger.info("[DISPLAY] Display update complete")
|
||||
else:
|
||||
logger.warning("No image to display")
|
||||
|
||||
def get_current_mode(self) -> EreaderMode:
|
||||
"""Get current application mode."""
|
||||
return self.state.mode
|
||||
|
||||
def get_overlay_state(self) -> OverlayState:
|
||||
"""Get current overlay state (only valid in reading mode)."""
|
||||
if self.reader:
|
||||
return self.reader.get_overlay_state()
|
||||
return OverlayState.NONE
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if application is running."""
|
||||
return self.running
|
||||
@@ -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']
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
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_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}")
|
||||
import traceback
|
||||
print(f"Full traceback:")
|
||||
traceback.print_exc()
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
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
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""
|
||||
Manages font size, spacing, font family, and rendering settings.
|
||||
|
||||
Responsibilities:
|
||||
- Font scale adjustment
|
||||
- Font family selection (serif, sans-serif, monospace)
|
||||
- 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.font_family: Optional[BundledFont] = None # None = use document default
|
||||
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
|
||||
self.font_family = manager.get_font_family()
|
||||
|
||||
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_font_family(self, font_family: Optional[BundledFont]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set the font family and re-render current page.
|
||||
|
||||
Args:
|
||||
font_family: BundledFont enum value (SERIF, SANS, MONOSPACE) or None for document default
|
||||
|
||||
Returns:
|
||||
Rendered page with new font family, or None if no manager
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
self.font_family = font_family
|
||||
page = self.manager.set_font_family(font_family)
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting font family: {e}")
|
||||
return None
|
||||
|
||||
def get_font_family(self) -> Optional[BundledFont]:
|
||||
"""
|
||||
Get the current font family.
|
||||
|
||||
Returns:
|
||||
Current BundledFont or None if using document default
|
||||
"""
|
||||
return self.font_family
|
||||
|
||||
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,
|
||||
'font_family': self.font_family.name if self.font_family else None,
|
||||
'line_spacing': 5,
|
||||
'inter_block_spacing': 15,
|
||||
'word_spacing': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'font_scale': self.font_scale,
|
||||
'font_family': self.font_family.name if self.font_family else None,
|
||||
'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, font_family, line_spacing, etc.)
|
||||
|
||||
Returns:
|
||||
True if settings applied successfully, False otherwise
|
||||
"""
|
||||
if not self.manager:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Apply font family
|
||||
font_family_name = settings.get('font_family', None)
|
||||
if font_family_name:
|
||||
try:
|
||||
font_family = BundledFont[font_family_name]
|
||||
if font_family != self.font_family:
|
||||
self.set_font_family(font_family)
|
||||
except KeyError:
|
||||
print(f"Warning: Unknown font family '{font_family_name}', using default")
|
||||
elif font_family_name is None and self.font_family is not None:
|
||||
# Restore to document default
|
||||
self.set_font_family(None)
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Overlay sub-applications for dreader.
|
||||
|
||||
Each overlay is a self-contained sub-application that handles its own:
|
||||
- HTML generation
|
||||
- Rendering logic
|
||||
- Gesture handling
|
||||
- State management
|
||||
"""
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from .navigation import NavigationOverlay
|
||||
from .settings import SettingsOverlay
|
||||
from .toc import TOCOverlay
|
||||
|
||||
__all__ = [
|
||||
'OverlaySubApplication',
|
||||
'NavigationOverlay',
|
||||
'SettingsOverlay',
|
||||
'TOCOverlay',
|
||||
]
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
This provides a common interface for all overlay types (TOC, Settings, Navigation, etc.)
|
||||
Each overlay is a self-contained sub-application that handles its own rendering and gestures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class OverlaySubApplication(ABC):
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
Each overlay type extends this class and implements:
|
||||
- open(): Generate HTML, render, and return composited image
|
||||
- handle_tap(): Process tap gestures within the overlay
|
||||
- close(): Clean up and return base page
|
||||
- get_overlay_type(): Return the OverlayState enum value
|
||||
|
||||
The base class provides:
|
||||
- Common rendering infrastructure (HTML to image conversion)
|
||||
- Coordinate translation (screen to overlay panel)
|
||||
- Query pixel support (detecting interactive elements)
|
||||
- Compositing (darkened background + centered panel)
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""
|
||||
Initialize overlay sub-application.
|
||||
|
||||
Args:
|
||||
reader: Reference to parent EbookReader instance
|
||||
"""
|
||||
self.reader = reader
|
||||
self.page_size = reader.page_size
|
||||
|
||||
# Overlay rendering state
|
||||
self._overlay_reader: Optional['EbookReader'] = None
|
||||
self._cached_base_page: Optional[Image.Image] = None
|
||||
self._cached_overlay_image: Optional[Image.Image] = None
|
||||
self._overlay_panel_offset: Tuple[int, int] = (0, 0)
|
||||
self._panel_size: Tuple[int, int] = (0, 0)
|
||||
|
||||
@abstractmethod
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""
|
||||
Get the overlay type identifier.
|
||||
|
||||
Returns:
|
||||
OverlayState enum value for this overlay
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the overlay and return composited image.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
**kwargs: Overlay-specific parameters
|
||||
|
||||
Returns:
|
||||
Composited image with overlay on top of base page
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap gesture within the overlay.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse indicating what action to take
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the overlay and clean up resources.
|
||||
|
||||
Returns:
|
||||
Base page image (without overlay), or None if not open
|
||||
"""
|
||||
base_page = self._cached_base_page
|
||||
|
||||
# Clear caches
|
||||
self._cached_base_page = None
|
||||
self._cached_overlay_image = None
|
||||
self._overlay_panel_offset = (0, 0)
|
||||
self._panel_size = (0, 0)
|
||||
|
||||
# Close overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
self._overlay_reader = None
|
||||
|
||||
return base_page
|
||||
|
||||
# ===================================================================
|
||||
# Common Infrastructure Methods
|
||||
# ===================================================================
|
||||
|
||||
def render_html_to_image(self, html: str, panel_size: Tuple[int, int]) -> Image.Image:
|
||||
"""
|
||||
Render HTML to image using a temporary EbookReader.
|
||||
|
||||
Args:
|
||||
html: HTML content to render
|
||||
panel_size: Size for the overlay panel (width, height)
|
||||
|
||||
Returns:
|
||||
Rendered PIL Image of the HTML
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from ..application import EbookReader
|
||||
|
||||
# Create or reuse overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
|
||||
self._overlay_reader = EbookReader(
|
||||
page_size=panel_size,
|
||||
margin=15,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the HTML content
|
||||
success = self._overlay_reader.load_html(
|
||||
html_string=html,
|
||||
title=f"{self.get_overlay_type().name} Overlay",
|
||||
author="",
|
||||
document_id=f"{self.get_overlay_type().name.lower()}_overlay"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError(f"Failed to load {self.get_overlay_type().name} overlay HTML")
|
||||
|
||||
# Get the rendered page
|
||||
return self._overlay_reader.get_current_page()
|
||||
|
||||
def composite_overlay(self, base_page: Image.Image, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay panel on top of base page with darkened background.
|
||||
|
||||
Creates popup effect by:
|
||||
1. Darkening the base image (70% brightness for e-ink visibility)
|
||||
2. Placing the overlay panel centered on top with a border
|
||||
|
||||
Args:
|
||||
base_page: Base reading page
|
||||
overlay_panel: Rendered overlay panel
|
||||
|
||||
Returns:
|
||||
Composited PIL Image with popup effect
|
||||
"""
|
||||
from PIL import ImageDraw, ImageEnhance
|
||||
import os
|
||||
|
||||
# Convert base image to RGB
|
||||
result = base_page.convert('RGB').copy()
|
||||
|
||||
# Lighten the background slightly (70% brightness for e-ink visibility)
|
||||
enhancer = ImageEnhance.Brightness(result)
|
||||
result = enhancer.enhance(0.7)
|
||||
|
||||
# Convert overlay panel to RGB
|
||||
if overlay_panel.mode != 'RGB':
|
||||
overlay_panel = overlay_panel.convert('RGB')
|
||||
|
||||
# DEBUG: Draw bounding boxes on interactive elements if debug mode enabled
|
||||
debug_mode = os.environ.get('DREADER_DEBUG_OVERLAY', '0') == '1'
|
||||
if debug_mode:
|
||||
overlay_panel = self._draw_debug_bounding_boxes(overlay_panel.copy())
|
||||
|
||||
# Calculate centered position for the panel
|
||||
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
|
||||
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
|
||||
|
||||
# Store panel position and size for coordinate translation
|
||||
self._overlay_panel_offset = (panel_x, panel_y)
|
||||
self._panel_size = (overlay_panel.width, overlay_panel.height)
|
||||
|
||||
# Add a thick black border around the panel for e-ink clarity
|
||||
draw = ImageDraw.Draw(result)
|
||||
border_width = 3
|
||||
draw.rectangle(
|
||||
[panel_x - border_width, panel_y - border_width,
|
||||
panel_x + overlay_panel.width + border_width,
|
||||
panel_y + overlay_panel.height + border_width],
|
||||
outline=(0, 0, 0),
|
||||
width=border_width
|
||||
)
|
||||
|
||||
# Paste the panel onto the dimmed background
|
||||
result.paste(overlay_panel, (panel_x, panel_y))
|
||||
|
||||
return result
|
||||
|
||||
def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query a pixel in the overlay to detect interactive elements.
|
||||
|
||||
Uses pyWebLayout's query_point() to detect tapped elements,
|
||||
including link targets and data attributes.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates to query
|
||||
|
||||
Returns:
|
||||
Dictionary with query result (text, link_target, is_interactive),
|
||||
or None if query failed or coordinates outside overlay
|
||||
"""
|
||||
if not self._overlay_reader:
|
||||
return None
|
||||
|
||||
# Translate screen coordinates to overlay panel coordinates
|
||||
panel_x, panel_y = self._overlay_panel_offset
|
||||
overlay_x = x - panel_x
|
||||
overlay_y = y - panel_y
|
||||
|
||||
# Check if coordinates are within the overlay panel
|
||||
if overlay_x < 0 or overlay_y < 0:
|
||||
return None
|
||||
|
||||
panel_width, panel_height = self._panel_size
|
||||
if overlay_x >= panel_width or overlay_y >= panel_height:
|
||||
return None
|
||||
|
||||
# Get the current page from the overlay reader
|
||||
if not self._overlay_reader.manager:
|
||||
return None
|
||||
|
||||
current_page = self._overlay_reader.manager.get_current_page()
|
||||
if not current_page:
|
||||
return None
|
||||
|
||||
# Query the point
|
||||
result = current_page.query_point((overlay_x, overlay_y))
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[OVERLAY_BASE] query_point({overlay_x}, {overlay_y}) returned: {result}")
|
||||
if result:
|
||||
logger.info(f"[OVERLAY_BASE] text={result.text}, link_target={result.link_target}, is_interactive={result.is_interactive}")
|
||||
logger.info(f"[OVERLAY_BASE] bounds={result.bounds}, object_type={result.object_type}")
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# Extract relevant data from QueryResult
|
||||
return {
|
||||
"text": result.text,
|
||||
"link_target": result.link_target,
|
||||
"is_interactive": result.is_interactive,
|
||||
"bounds": result.bounds,
|
||||
"object_type": result.object_type
|
||||
}
|
||||
|
||||
def _calculate_panel_size(self, width_ratio: float = 0.6, height_ratio: float = 0.7) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate overlay panel size as a percentage of screen size.
|
||||
|
||||
Args:
|
||||
width_ratio: Panel width as ratio of screen width (default 60%)
|
||||
height_ratio: Panel height as ratio of screen height (default 70%)
|
||||
|
||||
Returns:
|
||||
Tuple of (panel_width, panel_height) in pixels
|
||||
"""
|
||||
panel_width = int(self.page_size[0] * width_ratio)
|
||||
panel_height = int(self.page_size[1] * height_ratio)
|
||||
return (panel_width, panel_height)
|
||||
|
||||
def _draw_debug_bounding_boxes(self, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Draw bounding boxes around all interactive elements for debugging.
|
||||
|
||||
This scans the overlay panel and draws red rectangles around all
|
||||
clickable elements to help visualize where users need to click.
|
||||
|
||||
Args:
|
||||
overlay_panel: Overlay panel image to annotate
|
||||
|
||||
Returns:
|
||||
Annotated overlay panel with bounding boxes
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not self._overlay_reader or not self._overlay_reader.manager:
|
||||
logger.warning("[DEBUG] No overlay reader available for debug visualization")
|
||||
return overlay_panel
|
||||
|
||||
page = self._overlay_reader.manager.get_current_page()
|
||||
if not page:
|
||||
logger.warning("[DEBUG] No page available for debug visualization")
|
||||
return overlay_panel
|
||||
|
||||
# Scan for all interactive elements
|
||||
panel_width, panel_height = overlay_panel.size
|
||||
link_regions = {} # link_target -> (min_x, min_y, max_x, max_y)
|
||||
|
||||
logger.info(f"[DEBUG] Scanning {panel_width}x{panel_height} overlay for interactive elements...")
|
||||
|
||||
# Scan with fine granularity to find all interactive pixels
|
||||
for y in range(0, panel_height, 2):
|
||||
for x in range(0, panel_width, 2):
|
||||
result = page.query_point((x, y))
|
||||
if result and result.link_target:
|
||||
if result.link_target not in link_regions:
|
||||
link_regions[result.link_target] = [x, y, x, y]
|
||||
else:
|
||||
# Expand bounding box
|
||||
link_regions[result.link_target][0] = min(link_regions[result.link_target][0], x)
|
||||
link_regions[result.link_target][1] = min(link_regions[result.link_target][1], y)
|
||||
link_regions[result.link_target][2] = max(link_regions[result.link_target][2], x)
|
||||
link_regions[result.link_target][3] = max(link_regions[result.link_target][3], y)
|
||||
|
||||
# Draw bounding boxes
|
||||
draw = ImageDraw.Draw(overlay_panel)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
logger.info(f"[DEBUG] Found {len(link_regions)} interactive regions")
|
||||
|
||||
for link_target, (min_x, min_y, max_x, max_y) in link_regions.items():
|
||||
# Draw red bounding box
|
||||
draw.rectangle(
|
||||
[min_x, min_y, max_x, max_y],
|
||||
outline=(255, 0, 0),
|
||||
width=2
|
||||
)
|
||||
|
||||
# Draw label
|
||||
label = link_target[:20] # Truncate if too long
|
||||
draw.text((min_x + 2, min_y - 12), label, fill=(255, 0, 0), font=font)
|
||||
|
||||
logger.info(f"[DEBUG] {link_target}: ({min_x}, {min_y}) to ({max_x}, {max_y})")
|
||||
|
||||
return overlay_panel
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Navigation overlay sub-application.
|
||||
|
||||
Provides tabbed interface for Contents (TOC) and Bookmarks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_navigation_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class NavigationOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Unified navigation overlay with Contents and Bookmarks tabs.
|
||||
|
||||
Features:
|
||||
- Tab switching between Contents and Bookmarks
|
||||
- Chapter navigation via clickable links
|
||||
- Bookmark navigation
|
||||
- Close button
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""Initialize navigation overlay."""
|
||||
super().__init__(reader)
|
||||
|
||||
# Tab state
|
||||
self._active_tab: str = "contents"
|
||||
self._cached_chapters: List[Tuple[str, int]] = []
|
||||
self._cached_bookmarks: List[Dict[str, Any]] = []
|
||||
|
||||
# Pagination state
|
||||
self._toc_page: int = 0 # Current page in TOC
|
||||
self._toc_items_per_page: int = 10 # Items per page
|
||||
self._bookmarks_page: int = 0 # Current page in bookmarks
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return NAVIGATION overlay type."""
|
||||
return OverlayState.NAVIGATION
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the navigation overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
chapters: List of (chapter_title, chapter_index) tuples
|
||||
bookmarks: List of bookmark dicts with 'name' and optional 'position'
|
||||
active_tab: Which tab to show initially ("contents" or "bookmarks")
|
||||
|
||||
Returns:
|
||||
Composited image with navigation overlay
|
||||
"""
|
||||
chapters = kwargs.get('chapters', [])
|
||||
bookmarks = kwargs.get('bookmarks', [])
|
||||
active_tab = kwargs.get('active_tab', 'contents')
|
||||
|
||||
# Store for later use (tab switching)
|
||||
self._cached_chapters = chapters
|
||||
self._cached_bookmarks = bookmarks
|
||||
self._active_tab = active_tab
|
||||
|
||||
# Reset pagination when opening
|
||||
self._toc_page = 0
|
||||
self._bookmarks_page = 0
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in chapters
|
||||
]
|
||||
|
||||
# Generate navigation HTML with tabs
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapter_data,
|
||||
bookmarks=bookmarks,
|
||||
active_tab=active_tab,
|
||||
page_size=panel_size,
|
||||
toc_page=self._toc_page,
|
||||
toc_items_per_page=self._toc_items_per_page,
|
||||
bookmarks_page=self._bookmarks_page
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within navigation overlay.
|
||||
|
||||
Detects:
|
||||
- Tab switching (tab:contents, tab:bookmarks)
|
||||
- Chapter selection (chapter:N)
|
||||
- Bookmark selection (bookmark:name)
|
||||
- Close button (action:close)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[NAV_OVERLAY] Handling tap at ({x}, {y})")
|
||||
logger.info(f"[NAV_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
|
||||
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
logger.info(f"[NAV_OVERLAY] Query result: {query_result}")
|
||||
|
||||
# If query failed (tap outside overlay panel), close it
|
||||
if query_result is None:
|
||||
logger.info(f"[NAV_OVERLAY] Tap outside overlay panel, closing")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a link
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
logger.info(f"[NAV_OVERLAY] Found interactive link: {link_target}")
|
||||
|
||||
# Parse "tab:tabname" format for tab switching
|
||||
if link_target.startswith("tab:"):
|
||||
tab_name = link_target.split(":", 1)[1]
|
||||
self._switch_tab(tab_name)
|
||||
return GestureResponse(ActionType.TAB_SWITCHED, {
|
||||
"tab": tab_name
|
||||
})
|
||||
|
||||
# Parse "chapter:N" format for chapter navigation
|
||||
elif link_target.startswith("chapter:"):
|
||||
try:
|
||||
chapter_idx = int(link_target.split(":")[1])
|
||||
|
||||
# Get chapter title for response
|
||||
chapter_title = None
|
||||
for title, idx in self._cached_chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.reader.jump_to_chapter(chapter_idx)
|
||||
|
||||
return GestureResponse(ActionType.CHAPTER_SELECTED, {
|
||||
"chapter_index": chapter_idx,
|
||||
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse "bookmark:name" format for bookmark navigation
|
||||
elif link_target.startswith("bookmark:"):
|
||||
bookmark_name = link_target.split(":", 1)[1]
|
||||
|
||||
# Load the bookmark position
|
||||
page = self.reader.load_position(bookmark_name)
|
||||
if page:
|
||||
return GestureResponse(ActionType.BOOKMARK_SELECTED, {
|
||||
"bookmark_name": bookmark_name
|
||||
})
|
||||
else:
|
||||
# Failed to load bookmark
|
||||
return GestureResponse(ActionType.ERROR, {
|
||||
"message": f"Failed to load bookmark: {bookmark_name}"
|
||||
})
|
||||
|
||||
# Parse "action:close" format for close button
|
||||
elif link_target.startswith("action:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
if action == "close":
|
||||
logger.info(f"[NAV_OVERLAY] Close button clicked")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Parse "page:direction" format for pagination
|
||||
elif link_target.startswith("page:"):
|
||||
direction = link_target.split(":", 1)[1]
|
||||
logger.info(f"[NAV_OVERLAY] Pagination button clicked: {direction}")
|
||||
self._handle_pagination(direction)
|
||||
return GestureResponse(ActionType.PAGE_CHANGED, {
|
||||
"direction": direction,
|
||||
"tab": self._active_tab
|
||||
})
|
||||
|
||||
# Tap inside overlay but not on interactive element - keep overlay open
|
||||
logger.info(f"[NAV_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def switch_tab(self, new_tab: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Switch between tabs in the navigation overlay.
|
||||
|
||||
Args:
|
||||
new_tab: Tab to switch to ("contents" or "bookmarks")
|
||||
|
||||
Returns:
|
||||
Updated image with new tab active
|
||||
"""
|
||||
return self._switch_tab(new_tab)
|
||||
|
||||
def _switch_tab(self, new_tab: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Internal tab switching implementation.
|
||||
|
||||
Args:
|
||||
new_tab: Tab to switch to
|
||||
|
||||
Returns:
|
||||
Updated composited image with new tab active
|
||||
"""
|
||||
if not self._cached_base_page:
|
||||
return None
|
||||
|
||||
self._active_tab = new_tab
|
||||
|
||||
# Regenerate overlay with new active tab
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in self._cached_chapters
|
||||
]
|
||||
|
||||
# Generate navigation HTML with new active tab
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapter_data,
|
||||
bookmarks=self._cached_bookmarks,
|
||||
active_tab=new_tab,
|
||||
page_size=panel_size,
|
||||
toc_page=self._toc_page,
|
||||
toc_items_per_page=self._toc_items_per_page,
|
||||
bookmarks_page=self._bookmarks_page
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Update cache
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(self._cached_base_page, overlay_panel)
|
||||
|
||||
def _handle_pagination(self, direction: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Handle pagination within the active tab.
|
||||
|
||||
Args:
|
||||
direction: Either "next" or "prev"
|
||||
|
||||
Returns:
|
||||
Updated composited image with new page, or None if invalid
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if self._active_tab == "contents":
|
||||
# Calculate total pages
|
||||
total_items = len(self._cached_chapters)
|
||||
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
|
||||
|
||||
# Update page number
|
||||
if direction == "next" and self._toc_page < total_pages - 1:
|
||||
self._toc_page += 1
|
||||
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
|
||||
elif direction == "prev" and self._toc_page > 0:
|
||||
self._toc_page -= 1
|
||||
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
|
||||
else:
|
||||
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._toc_page + 1}/{total_pages}")
|
||||
return None
|
||||
|
||||
elif self._active_tab == "bookmarks":
|
||||
# Calculate total pages
|
||||
total_items = len(self._cached_bookmarks)
|
||||
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
|
||||
|
||||
# Update page number
|
||||
if direction == "next" and self._bookmarks_page < total_pages - 1:
|
||||
self._bookmarks_page += 1
|
||||
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
|
||||
elif direction == "prev" and self._bookmarks_page > 0:
|
||||
self._bookmarks_page -= 1
|
||||
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
|
||||
else:
|
||||
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._bookmarks_page + 1}/{total_pages}")
|
||||
return None
|
||||
|
||||
# Regenerate the overlay with new page
|
||||
return self._switch_tab(self._active_tab)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Settings overlay sub-application.
|
||||
|
||||
Provides interactive controls for adjusting reading settings with live preview.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_settings_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class SettingsOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Settings overlay with live preview.
|
||||
|
||||
Features:
|
||||
- Font size adjustment (increase/decrease)
|
||||
- Line spacing adjustment
|
||||
- Inter-block spacing adjustment
|
||||
- Word spacing adjustment
|
||||
- Live preview of changes on base page
|
||||
- Back to library button
|
||||
"""
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return SETTINGS overlay type."""
|
||||
return OverlayState.SETTINGS
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the settings overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
font_scale: Current font scale
|
||||
line_spacing: Current line spacing in pixels
|
||||
inter_block_spacing: Current inter-block spacing in pixels
|
||||
word_spacing: Current word spacing in pixels
|
||||
font_family: Current font family name (e.g., "SERIF", "SANS", "MONOSPACE", or None)
|
||||
|
||||
Returns:
|
||||
Composited image with settings overlay
|
||||
"""
|
||||
font_scale = kwargs.get('font_scale', 1.0)
|
||||
line_spacing = kwargs.get('line_spacing', 5)
|
||||
inter_block_spacing = kwargs.get('inter_block_spacing', 15)
|
||||
word_spacing = kwargs.get('word_spacing', 0)
|
||||
font_family = kwargs.get('font_family', 'Default')
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Generate settings HTML with current values
|
||||
html = generate_settings_overlay(
|
||||
font_scale=font_scale,
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing,
|
||||
word_spacing=word_spacing,
|
||||
font_family=font_family,
|
||||
page_size=panel_size
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within settings overlay.
|
||||
|
||||
Detects:
|
||||
- Setting adjustment controls (setting:action)
|
||||
- Back to library button (action:back_to_library)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[SETTINGS_OVERLAY] Handling tap at ({x}, {y})")
|
||||
logger.info(f"[SETTINGS_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
|
||||
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
logger.info(f"[SETTINGS_OVERLAY] Query result: {query_result}")
|
||||
|
||||
# If query failed (tap outside overlay panel), close it
|
||||
if query_result is None:
|
||||
logger.info(f"[SETTINGS_OVERLAY] Tap outside overlay panel, closing")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a settings control link
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
logger.info(f"[SETTINGS_OVERLAY] Found interactive link: {link_target}")
|
||||
|
||||
# Parse "setting:action" format
|
||||
if link_target.startswith("setting:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
logger.info(f"[SETTINGS_OVERLAY] Applying setting change: {action}")
|
||||
return self._apply_setting_change(action)
|
||||
|
||||
# Parse "action:command" format for other actions
|
||||
elif link_target.startswith("action:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
|
||||
if action == "back_to_library":
|
||||
logger.info(f"[SETTINGS_OVERLAY] Back to library clicked")
|
||||
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
|
||||
|
||||
# Tap inside overlay but not on interactive element - keep overlay open
|
||||
logger.info(f"[SETTINGS_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def refresh(self, updated_base_page: Image.Image,
|
||||
font_scale: float,
|
||||
line_spacing: int,
|
||||
inter_block_spacing: int,
|
||||
word_spacing: int = 0,
|
||||
font_family: str = "Default") -> Image.Image:
|
||||
"""
|
||||
Refresh the settings overlay with updated values and background page.
|
||||
|
||||
This is used for live preview when settings change - it updates both
|
||||
the background page (with new settings applied) and the overlay panel
|
||||
(with new values displayed).
|
||||
|
||||
Args:
|
||||
updated_base_page: Updated reading page with new settings applied
|
||||
font_scale: Updated font scale
|
||||
line_spacing: Updated line spacing
|
||||
inter_block_spacing: Updated inter-block spacing
|
||||
word_spacing: Updated word spacing
|
||||
font_family: Updated font family
|
||||
|
||||
Returns:
|
||||
Composited image with updated settings overlay
|
||||
"""
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Generate updated settings HTML
|
||||
html = generate_settings_overlay(
|
||||
font_scale=font_scale,
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing,
|
||||
word_spacing=word_spacing,
|
||||
font_family=font_family,
|
||||
page_size=panel_size
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Update caches
|
||||
self._cached_base_page = updated_base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(updated_base_page, overlay_panel)
|
||||
|
||||
def _apply_setting_change(self, action: str) -> GestureResponse:
|
||||
"""
|
||||
Apply a setting change and refresh the overlay.
|
||||
|
||||
Args:
|
||||
action: Setting action (e.g., "font_increase", "line_spacing_decrease", "font_family_serif")
|
||||
|
||||
Returns:
|
||||
GestureResponse with SETTING_CHANGED action
|
||||
"""
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
# Apply the setting change via reader
|
||||
if action == "font_increase":
|
||||
self.reader.increase_font_size()
|
||||
elif action == "font_decrease":
|
||||
self.reader.decrease_font_size()
|
||||
elif action == "font_family_default":
|
||||
self.reader.set_font_family(None)
|
||||
elif action == "font_family_serif":
|
||||
self.reader.set_font_family(BundledFont.SERIF)
|
||||
elif action == "font_family_sans":
|
||||
self.reader.set_font_family(BundledFont.SANS)
|
||||
elif action == "font_family_monospace":
|
||||
self.reader.set_font_family(BundledFont.MONOSPACE)
|
||||
elif action == "line_spacing_increase":
|
||||
new_spacing = self.reader.page_style.line_spacing + 2
|
||||
self.reader.set_line_spacing(new_spacing)
|
||||
elif action == "line_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.line_spacing - 2)
|
||||
self.reader.set_line_spacing(new_spacing)
|
||||
elif action == "block_spacing_increase":
|
||||
new_spacing = self.reader.page_style.inter_block_spacing + 3
|
||||
self.reader.set_inter_block_spacing(new_spacing)
|
||||
elif action == "block_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.inter_block_spacing - 3)
|
||||
self.reader.set_inter_block_spacing(new_spacing)
|
||||
elif action == "word_spacing_increase":
|
||||
new_spacing = self.reader.page_style.word_spacing + 2
|
||||
self.reader.set_word_spacing(new_spacing)
|
||||
elif action == "word_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.word_spacing - 2)
|
||||
self.reader.set_word_spacing(new_spacing)
|
||||
|
||||
# Re-render the base page with new settings applied
|
||||
# Must get directly from manager, not get_current_page() which returns overlay
|
||||
page = self.reader.manager.get_current_page()
|
||||
updated_page = page.render()
|
||||
|
||||
# Get font family for display
|
||||
font_family = self.reader.get_font_family()
|
||||
font_family_name = font_family.name if font_family else "Default"
|
||||
|
||||
# Refresh the settings overlay with updated values and page
|
||||
self.refresh(
|
||||
updated_base_page=updated_page,
|
||||
font_scale=self.reader.base_font_scale,
|
||||
line_spacing=self.reader.page_style.line_spacing,
|
||||
inter_block_spacing=self.reader.page_style.inter_block_spacing,
|
||||
word_spacing=self.reader.page_style.word_spacing,
|
||||
font_family=font_family_name
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SETTING_CHANGED, {
|
||||
"action": action,
|
||||
"font_scale": self.reader.base_font_scale,
|
||||
"font_family": font_family_name,
|
||||
"line_spacing": self.reader.page_style.line_spacing,
|
||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing,
|
||||
"word_spacing": self.reader.page_style.word_spacing
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Table of Contents overlay sub-application.
|
||||
|
||||
Simple TOC overlay (deprecated in favor of NavigationOverlay).
|
||||
Kept for backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_toc_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class TOCOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Simple Table of Contents overlay.
|
||||
|
||||
NOTE: This is deprecated in favor of NavigationOverlay which provides
|
||||
a unified interface for both TOC and bookmarks. Kept for backward compatibility.
|
||||
|
||||
Features:
|
||||
- List of chapters with clickable links
|
||||
- Chapter navigation
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""Initialize TOC overlay."""
|
||||
super().__init__(reader)
|
||||
self._cached_chapters: List[Tuple[str, int]] = []
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return TOC overlay type."""
|
||||
return OverlayState.TOC
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the TOC overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
chapters: List of (chapter_title, chapter_index) tuples
|
||||
|
||||
Returns:
|
||||
Composited image with TOC overlay
|
||||
"""
|
||||
chapters = kwargs.get('chapters', [])
|
||||
|
||||
# Store for later use
|
||||
self._cached_chapters = chapters
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in chapters
|
||||
]
|
||||
|
||||
# Generate TOC HTML with clickable links
|
||||
html = generate_toc_overlay(chapter_data, page_size=panel_size)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within TOC overlay.
|
||||
|
||||
Detects:
|
||||
- Chapter selection (chapter:N)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
# If query failed (tap outside overlay), close it
|
||||
if not query_result:
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a link (chapter)
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
|
||||
# Parse "chapter:N" format
|
||||
if link_target.startswith("chapter:"):
|
||||
try:
|
||||
chapter_idx = int(link_target.split(":")[1])
|
||||
|
||||
# Get chapter title for response
|
||||
chapter_title = None
|
||||
for title, idx in self._cached_chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.reader.jump_to_chapter(chapter_idx)
|
||||
|
||||
return GestureResponse(ActionType.CHAPTER_SELECTED, {
|
||||
"chapter_index": chapter_idx,
|
||||
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Not a chapter link, close overlay
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
State management for dreader application.
|
||||
|
||||
Handles application state persistence with asyncio-based auto-save functionality.
|
||||
State is saved to a JSON file and includes current mode, book position, settings, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
|
||||
class EreaderMode(Enum):
|
||||
"""Application mode states"""
|
||||
LIBRARY = "library"
|
||||
READING = "reading"
|
||||
|
||||
|
||||
class OverlayState(Enum):
|
||||
"""Overlay states within READING mode"""
|
||||
NONE = "none"
|
||||
TOC = "toc" # Deprecated: use NAVIGATION instead
|
||||
SETTINGS = "settings"
|
||||
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
|
||||
NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookState:
|
||||
"""State for currently open book - just the path and metadata"""
|
||||
path: str
|
||||
title: str = ""
|
||||
author: str = ""
|
||||
last_read_timestamp: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'BookState':
|
||||
"""Create from dictionary"""
|
||||
return cls(
|
||||
path=data['path'],
|
||||
title=data.get('title', ''),
|
||||
author=data.get('author', ''),
|
||||
last_read_timestamp=data.get('last_read_timestamp', '')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibraryState:
|
||||
"""State for library view"""
|
||||
books_path: str = ""
|
||||
last_selected_index: int = 0
|
||||
scan_cache: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'LibraryState':
|
||||
"""Create from dictionary"""
|
||||
return cls(
|
||||
books_path=data.get('books_path', ''),
|
||||
last_selected_index=data.get('last_selected_index', 0),
|
||||
scan_cache=data.get('scan_cache', [])
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""User settings for rendering and display"""
|
||||
font_scale: float = 1.0
|
||||
line_spacing: int = 5
|
||||
inter_block_spacing: int = 15
|
||||
word_spacing: int = 0 # Default word spacing
|
||||
brightness: int = 8
|
||||
theme: str = "day"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Settings':
|
||||
"""Create from dictionary"""
|
||||
return cls(
|
||||
font_scale=data.get('font_scale', 1.0),
|
||||
line_spacing=data.get('line_spacing', 5),
|
||||
inter_block_spacing=data.get('inter_block_spacing', 15),
|
||||
word_spacing=data.get('word_spacing', 0),
|
||||
brightness=data.get('brightness', 8),
|
||||
theme=data.get('theme', 'day')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppState:
|
||||
"""Complete application state"""
|
||||
version: str = "1.0"
|
||||
mode: EreaderMode = EreaderMode.LIBRARY
|
||||
overlay: OverlayState = OverlayState.NONE
|
||||
current_book: Optional[BookState] = None
|
||||
library: LibraryState = field(default_factory=LibraryState)
|
||||
settings: Settings = field(default_factory=Settings)
|
||||
bookmarks: Dict[str, List[str]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
'version': self.version,
|
||||
'mode': self.mode.value,
|
||||
'overlay': self.overlay.value,
|
||||
'current_book': self.current_book.to_dict() if self.current_book else None,
|
||||
'library': self.library.to_dict(),
|
||||
'settings': self.settings.to_dict(),
|
||||
'bookmarks': self.bookmarks
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'AppState':
|
||||
"""Create from dictionary"""
|
||||
current_book = None
|
||||
if data.get('current_book'):
|
||||
current_book = BookState.from_dict(data['current_book'])
|
||||
|
||||
return cls(
|
||||
version=data.get('version', '1.0'),
|
||||
mode=EreaderMode(data.get('mode', 'library')),
|
||||
overlay=OverlayState(data.get('overlay', 'none')),
|
||||
current_book=current_book,
|
||||
library=LibraryState.from_dict(data.get('library', {})),
|
||||
settings=Settings.from_dict(data.get('settings', {})),
|
||||
bookmarks=data.get('bookmarks', {})
|
||||
)
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""
|
||||
Manages application state with persistence and auto-save.
|
||||
|
||||
Features:
|
||||
- Load/save state to JSON file
|
||||
- Asyncio-based auto-save timer (every 60 seconds)
|
||||
- Atomic writes (write to temp file, then rename)
|
||||
- Backup of previous state on corruption
|
||||
- Thread-safe state updates
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: Optional[str] = None, auto_save_interval: int = 60):
|
||||
"""
|
||||
Initialize state manager.
|
||||
|
||||
Args:
|
||||
state_file: Path to state file. If None, uses default location.
|
||||
auto_save_interval: Auto-save interval in seconds (default: 60)
|
||||
"""
|
||||
if state_file:
|
||||
self.state_file = Path(state_file)
|
||||
else:
|
||||
self.state_file = self._get_default_state_file()
|
||||
|
||||
self.auto_save_interval = auto_save_interval
|
||||
self.state = AppState()
|
||||
self._dirty = False
|
||||
self._save_task: Optional[asyncio.Task] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Ensure state directory exists
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _get_default_state_file() -> Path:
|
||||
"""Get default state file location based on platform"""
|
||||
if os.name == 'nt': # Windows
|
||||
config_dir = Path(os.environ.get('APPDATA', '~/.config'))
|
||||
else: # Linux/Mac
|
||||
config_dir = Path.home() / '.config'
|
||||
|
||||
return config_dir / 'dreader' / 'state.json'
|
||||
|
||||
def load_state(self) -> AppState:
|
||||
"""
|
||||
Load state from file.
|
||||
|
||||
Returns:
|
||||
Loaded AppState, or default AppState if file doesn't exist or is corrupt
|
||||
"""
|
||||
if not self.state_file.exists():
|
||||
print(f"No state file found at {self.state_file}, using defaults")
|
||||
return AppState()
|
||||
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.state = AppState.from_dict(data)
|
||||
self._dirty = False
|
||||
print(f"State loaded from {self.state_file}")
|
||||
|
||||
# Clear overlay state on boot (always start without overlays)
|
||||
if self.state.overlay != OverlayState.NONE:
|
||||
print("Clearing overlay state on boot")
|
||||
self.state.overlay = OverlayState.NONE
|
||||
self._dirty = True
|
||||
|
||||
return self.state
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading state from {self.state_file}: {e}")
|
||||
|
||||
# Backup corrupt file
|
||||
backup_path = self.state_file.with_suffix('.json.backup')
|
||||
try:
|
||||
shutil.copy2(self.state_file, backup_path)
|
||||
print(f"Backed up corrupt state to {backup_path}")
|
||||
except Exception as backup_error:
|
||||
print(f"Failed to backup corrupt state: {backup_error}")
|
||||
|
||||
# Return default state
|
||||
self.state = AppState()
|
||||
self._dirty = True
|
||||
return self.state
|
||||
|
||||
def save_state(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Save state to file (synchronous).
|
||||
|
||||
Args:
|
||||
force: Save even if state is not dirty
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise
|
||||
"""
|
||||
if not force and not self._dirty:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Atomic write: write to temp file, then rename
|
||||
temp_fd, temp_path = tempfile.mkstemp(
|
||||
dir=self.state_file.parent,
|
||||
prefix='.state_',
|
||||
suffix='.json.tmp'
|
||||
)
|
||||
|
||||
try:
|
||||
with os.fdopen(temp_fd, 'w') as f:
|
||||
json.dump(self.state.to_dict(), f, indent=2)
|
||||
|
||||
# Atomic rename
|
||||
os.replace(temp_path, self.state_file)
|
||||
self._dirty = False
|
||||
print(f"State saved to {self.state_file}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving state: {e}")
|
||||
return False
|
||||
|
||||
async def save_state_async(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Save state to file (async version).
|
||||
|
||||
Args:
|
||||
force: Save even if state is not dirty
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise
|
||||
"""
|
||||
async with self._lock:
|
||||
# Run sync save in executor to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.save_state, force)
|
||||
|
||||
async def _auto_save_loop(self):
|
||||
"""Background task for automatic state saving"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.auto_save_interval)
|
||||
if self._dirty:
|
||||
print(f"Auto-saving state (interval: {self.auto_save_interval}s)")
|
||||
await self.save_state_async()
|
||||
except asyncio.CancelledError:
|
||||
print("Auto-save loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in auto-save loop: {e}")
|
||||
|
||||
def start_auto_save(self):
|
||||
"""Start the auto-save background task"""
|
||||
if self._save_task is None or self._save_task.done():
|
||||
self._save_task = asyncio.create_task(self._auto_save_loop())
|
||||
print(f"Auto-save started (interval: {self.auto_save_interval}s)")
|
||||
|
||||
async def stop_auto_save(self, save_final: bool = True):
|
||||
"""
|
||||
Stop the auto-save background task.
|
||||
|
||||
Args:
|
||||
save_final: Whether to perform a final save before stopping
|
||||
"""
|
||||
if self._save_task and not self._save_task.done():
|
||||
self._save_task.cancel()
|
||||
try:
|
||||
await self._save_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if save_final:
|
||||
await self.save_state_async(force=True)
|
||||
print("Final state save completed")
|
||||
|
||||
# Convenience methods for state access
|
||||
|
||||
def get_mode(self) -> EreaderMode:
|
||||
"""Get current application mode"""
|
||||
return self.state.mode
|
||||
|
||||
def set_mode(self, mode: EreaderMode):
|
||||
"""Set application mode"""
|
||||
if self.state.mode != mode:
|
||||
self.state.mode = mode
|
||||
self._dirty = True
|
||||
|
||||
def get_overlay(self) -> OverlayState:
|
||||
"""Get current overlay state"""
|
||||
return self.state.overlay
|
||||
|
||||
def set_overlay(self, overlay: OverlayState):
|
||||
"""Set overlay state"""
|
||||
if self.state.overlay != overlay:
|
||||
self.state.overlay = overlay
|
||||
self._dirty = True
|
||||
|
||||
def get_current_book(self) -> Optional[BookState]:
|
||||
"""Get current book state"""
|
||||
return self.state.current_book
|
||||
|
||||
def set_current_book(self, book: Optional[BookState]):
|
||||
"""Set current book state"""
|
||||
self.state.current_book = book
|
||||
if book:
|
||||
book.last_read_timestamp = datetime.now().isoformat()
|
||||
self._dirty = True
|
||||
|
||||
def update_book_timestamp(self):
|
||||
"""Update current book's last read timestamp"""
|
||||
if self.state.current_book:
|
||||
self.state.current_book.last_read_timestamp = datetime.now().isoformat()
|
||||
self._dirty = True
|
||||
|
||||
def get_settings(self) -> Settings:
|
||||
"""Get user settings"""
|
||||
return self.state.settings
|
||||
|
||||
def update_setting(self, key: str, value: Any):
|
||||
"""Update a single setting"""
|
||||
if hasattr(self.state.settings, key):
|
||||
setattr(self.state.settings, key, value)
|
||||
self._dirty = True
|
||||
|
||||
def update_settings(self, settings_dict: Dict[str, Any]):
|
||||
"""
|
||||
Update multiple settings at once.
|
||||
|
||||
Args:
|
||||
settings_dict: Dictionary with setting keys and values
|
||||
"""
|
||||
for key, value in settings_dict.items():
|
||||
if hasattr(self.state.settings, key):
|
||||
setattr(self.state.settings, key, value)
|
||||
self._dirty = True
|
||||
|
||||
def get_library_state(self) -> LibraryState:
|
||||
"""Get library state"""
|
||||
return self.state.library
|
||||
|
||||
def update_library_cache(self, cache: List[Dict[str, Any]]):
|
||||
"""Update library scan cache"""
|
||||
self.state.library.scan_cache = cache
|
||||
self._dirty = True
|
||||
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if state has unsaved changes"""
|
||||
return self._dirty
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Mark state as having unsaved changes"""
|
||||
self._dirty = True
|
||||
Reference in New Issue
Block a user