Duncan Tourolle 01e79dfa4b
All checks were successful
Python CI / test (3.12) (push) Successful in 22m19s
Python CI / test (3.13) (push) Successful in 8m23s
Test appplication for offdevice testing
2025-11-09 17:47:34 +01:00

429 lines
15 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.
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")
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