Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2025-11-23 14:10:14 +00:00
commit f83411929c
107 changed files with 23106 additions and 0 deletions
+66
View File
@@ -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
+285
View File
@@ -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')
+135
View File
@@ -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"
+313
View File
@@ -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
View File
@@ -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
+675
View File
@@ -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
+406
View File
@@ -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()
+10
View File
@@ -0,0 +1,10 @@
"""
Handlers module for dreader application.
This module contains interaction handlers:
- GestureRouter: Routes touch events to appropriate handlers
"""
from .gestures import GestureRouter
__all__ = ['GestureRouter']
+283
View File
@@ -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, {})
+763
View File
@@ -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
+586
View File
@@ -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
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
"""
Managers module for dreader application.
This module contains business logic managers that handle specific responsibilities:
- DocumentManager: Document loading and metadata
- SettingsManager: Font size, spacing, and rendering settings
- HighlightCoordinator: Highlight operations coordination
"""
from .document import DocumentManager
from .settings import SettingsManager
from .highlight_coordinator import HighlightCoordinator
__all__ = ['DocumentManager', 'SettingsManager', 'HighlightCoordinator']
+140
View File
@@ -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
+211
View File
@@ -0,0 +1,211 @@
"""
Highlight operations coordination.
This module coordinates highlight operations with the highlight manager.
"""
from __future__ import annotations
from typing import List, Tuple, Optional, TYPE_CHECKING
from PIL import Image
import numpy as np
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
if TYPE_CHECKING:
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
class HighlightCoordinator:
"""
Coordinates highlight operations.
This class provides a simplified interface for highlighting operations,
coordinating between the layout manager and highlight manager.
"""
def __init__(self, document_id: str, highlights_dir: str):
"""
Initialize the highlight coordinator.
Args:
document_id: Unique document identifier
highlights_dir: Directory to store highlights
"""
self.highlight_manager = HighlightManager(
document_id=document_id,
highlights_dir=highlights_dir
)
self.layout_manager: Optional['EreaderLayoutManager'] = None
def set_layout_manager(self, manager: 'EreaderLayoutManager'):
"""Set the layout manager."""
self.layout_manager = manager
def highlight_word(self, x: int, y: int,
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a word at the given pixel location.
Args:
x: X coordinate
y: Y coordinate
color: RGBA color tuple (defaults to yellow)
note: Optional annotation for this highlight
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
# Query the pixel to find the word
page = self.layout_manager.get_current_page()
result = page.query_point((x, y))
if not result or not result.text:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from query result
highlight = create_highlight_from_query_result(
result,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting word: {e}")
return None
def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int],
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a range of words between two points.
Args:
start: Starting (x, y) coordinates
end: Ending (x, y) coordinates
color: RGBA color tuple (defaults to yellow)
note: Optional annotation
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
page = self.layout_manager.get_current_page()
selection_range = page.query_range(start, end)
if not selection_range.results:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from selection range
highlight = create_highlight_from_query_result(
selection_range,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting selection: {e}")
return None
def remove_highlight(self, highlight_id: str) -> bool:
"""Remove a highlight by ID."""
return self.highlight_manager.remove_highlight(highlight_id)
def list_highlights(self) -> List[Highlight]:
"""Get all highlights for the current document."""
return self.highlight_manager.list_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""Get highlights that appear on a specific page."""
return self.highlight_manager.get_highlights_for_page(page_bounds)
def clear_all(self) -> None:
"""Remove all highlights from the current document."""
self.highlight_manager.clear_all()
def render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image:
"""
Render highlight overlays on an image using multiply blend mode.
Args:
image: Base PIL Image to draw on
highlights: List of Highlight objects to render
Returns:
New PIL Image with highlights overlaid
"""
# Convert to RGB for processing
original_mode = image.mode
if image.mode == 'RGBA':
rgb_image = image.convert('RGB')
alpha_channel = image.split()[-1]
else:
rgb_image = image.convert('RGB')
alpha_channel = None
# Convert to numpy array for efficient processing
img_array = np.array(rgb_image, dtype=np.float32)
# Process each highlight
for highlight in highlights:
# Extract RGB components from highlight color (ignore alpha)
h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2]
# Create highlight multiplier (normalize to 0-1 range)
highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32)
for hx, hy, hw, hh in highlight.bounds:
# Ensure bounds are within image
hx, hy = max(0, hx), max(0, hy)
x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh)
if x2 <= hx or y2 <= hy:
continue
# Extract the region to highlight
region = img_array[hy:y2, hx:x2, :]
# Multiply with highlight color (like a real highlighter)
highlighted = region * highlight_color
# Put the highlighted region back
img_array[hy:y2, hx:x2, :] = highlighted
# Convert back to uint8 and create PIL Image
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
result = Image.fromarray(img_array, mode='RGB')
# Restore alpha channel if original had one
if alpha_channel is not None and original_mode == 'RGBA':
result = result.convert('RGBA')
result.putalpha(alpha_channel)
return result
+299
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
"""
Overlay sub-applications for dreader.
Each overlay is a self-contained sub-application that handles its own:
- HTML generation
- Rendering logic
- Gesture handling
- State management
"""
from .base import OverlaySubApplication
from .navigation import NavigationOverlay
from .settings import SettingsOverlay
from .toc import TOCOverlay
__all__ = [
'OverlaySubApplication',
'NavigationOverlay',
'SettingsOverlay',
'TOCOverlay',
]
+359
View File
@@ -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
+311
View File
@@ -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)
+251
View File
@@ -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
})
+128
View File
@@ -0,0 +1,128 @@
"""
Table of Contents overlay sub-application.
Simple TOC overlay (deprecated in favor of NavigationOverlay).
Kept for backward compatibility.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple
from PIL import Image
from .base import OverlaySubApplication
from ..gesture import GestureResponse, ActionType
from ..state import OverlayState
from ..html_generator import generate_toc_overlay
if TYPE_CHECKING:
from ..application import EbookReader
class TOCOverlay(OverlaySubApplication):
"""
Simple Table of Contents overlay.
NOTE: This is deprecated in favor of NavigationOverlay which provides
a unified interface for both TOC and bookmarks. Kept for backward compatibility.
Features:
- List of chapters with clickable links
- Chapter navigation
"""
def __init__(self, reader: 'EbookReader'):
"""Initialize TOC overlay."""
super().__init__(reader)
self._cached_chapters: List[Tuple[str, int]] = []
def get_overlay_type(self) -> OverlayState:
"""Return TOC overlay type."""
return OverlayState.TOC
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
"""
Open the TOC overlay.
Args:
base_page: Current reading page to show underneath
chapters: List of (chapter_title, chapter_index) tuples
Returns:
Composited image with TOC overlay
"""
chapters = kwargs.get('chapters', [])
# Store for later use
self._cached_chapters = chapters
# Calculate panel size (60% width, 70% height)
panel_size = self._calculate_panel_size(0.6, 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate TOC HTML with clickable links
html = generate_toc_overlay(chapter_data, page_size=panel_size)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def handle_tap(self, x: int, y: int) -> GestureResponse:
"""
Handle tap within TOC overlay.
Detects:
- Chapter selection (chapter:N)
- Tap outside overlay (closes)
Args:
x, y: Screen coordinates of tap
Returns:
GestureResponse with appropriate action
"""
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a link (chapter)
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
# Parse "chapter:N" format
if link_target.startswith("chapter:"):
try:
chapter_idx = int(link_target.split(":")[1])
# Get chapter title for response
chapter_title = None
for title, idx in self._cached_chapters:
if idx == chapter_idx:
chapter_title = title
break
# Jump to selected chapter
self.reader.jump_to_chapter(chapter_idx)
return GestureResponse(ActionType.CHAPTER_SELECTED, {
"chapter_index": chapter_idx,
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
})
except (ValueError, IndexError):
pass
# Not a chapter link, close overlay
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
+407
View File
@@ -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