454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""
|
|
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.debug(f"Updating display: {self.current_image.size}")
|
|
await self.display_hal.show_image(self.current_image)
|
|
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
|