Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed05dc9c2d | ||
|
|
f7d59d025f | ||
|
|
c62b8eff38 | ||
|
|
73102392dd | ||
|
|
5f38e3865e | ||
|
|
0e6a8d53eb | ||
|
|
25b20fdbbd | ||
|
|
a1775baa76 | ||
|
|
70c0b4a1f2 | ||
|
|
5a573c901e | ||
|
|
4b4e9612a0 | ||
|
|
c76f8696c7 | ||
|
|
d6bdc2ce40 | ||
|
|
dd392d2e15 | ||
|
|
8f6205dcfe | ||
|
|
0f9e38eb7c | ||
|
|
a552eb0951 | ||
|
|
01e79dfa4b | ||
|
|
678e1acf29 | ||
|
|
b1e75528e5 | ||
|
|
b2ede1c481 | ||
|
|
472606dfa5 | ||
|
|
31e4c0c1ee | ||
|
|
2aae1a88ea | ||
|
|
18be4306bf | ||
|
|
7518bcf835 | ||
|
|
5d3e7fae7b | ||
|
|
b5ae08c2b5 | ||
|
|
cda1ec3363 | ||
|
|
fe140ba91f | ||
|
|
4811367905 | ||
|
|
11dc30ba8d | ||
|
|
531d02735c | ||
|
|
1993b0bf48 |
@@ -0,0 +1,2 @@
|
||||
# Mark EPUB files as binary to prevent any text transformations
|
||||
*.epub binary
|
||||
@@ -12,6 +12,10 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -19,18 +23,21 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install package in development mode
|
||||
# Install package in development mode (force reinstall pyweblayout to get latest from master)
|
||||
pip install --upgrade --force-reinstall --no-deps --no-cache-dir git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
|
||||
pip install -e .
|
||||
# Install test dependencies if they exist
|
||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
|
||||
if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi
|
||||
# Install common test packages
|
||||
pip install pytest pytest-cov flake8 coverage-badge interrogate
|
||||
# Debug: Show pyWebLayout version info
|
||||
python -c "import pyWebLayout; print(f'pyWebLayout location: {pyWebLayout.__file__}')"
|
||||
|
||||
- name: Download initial failed badges
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "external/dreader-hal"]
|
||||
path = external/dreader-hal
|
||||
url = https://gitea.tourolle.paris/dtourolle/dreader-hal
|
||||
@@ -1,210 +0,0 @@
|
||||
# HTML Generation for dreader
|
||||
|
||||
This document describes how to use the HTML generation features in dreader to create UI for e-reader applications.
|
||||
|
||||
## Overview
|
||||
|
||||
The dreader library now includes HTML generation capabilities that allow you to create complete user interfaces programmatically. This is designed to work with a Hardware Abstraction Layer (HAL) that handles the actual display rendering and input processing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ dreader Library │
|
||||
│ ├─ EbookReader (book rendering) │
|
||||
│ ├─ html_generator (UI generation) │
|
||||
│ └─ book_utils (scanning/metadata) │
|
||||
└─────────────────────────────────────┘
|
||||
↓ HTML strings
|
||||
┌─────────────────────────────────────┐
|
||||
│ HAL (Hardware Abstraction Layer) │
|
||||
│ - Receives HTML strings │
|
||||
│ - Renders to display │
|
||||
│ - Captures touch/button input │
|
||||
│ - Calls back to dreader │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Page and Overlay Concept
|
||||
|
||||
The UI uses a **page/overlay** architecture:
|
||||
|
||||
- **Page (background)**: The main book content rendered as an image
|
||||
- **Overlay (foreground)**: UI elements like settings, table of contents, bookmarks, etc.
|
||||
|
||||
## Available Modules
|
||||
|
||||
### 1. html_generator
|
||||
|
||||
Functions for generating HTML strings:
|
||||
|
||||
- `generate_library_html(books)` - Grid view of all books with covers
|
||||
- `generate_reader_html(title, author, page_data)` - Book reading view
|
||||
- `generate_settings_overlay()` - Settings panel
|
||||
- `generate_toc_overlay(chapters)` - Table of contents
|
||||
- `generate_bookmarks_overlay(bookmarks)` - Bookmarks list
|
||||
|
||||
### 2. book_utils
|
||||
|
||||
Utilities for managing books:
|
||||
|
||||
- `scan_book_directory(path)` - Scan directory for EPUB files
|
||||
- `extract_book_metadata(epub_path)` - Get title, author, cover
|
||||
- `get_chapter_list(reader)` - Format chapters for TOC
|
||||
- `get_bookmark_list(reader)` - Format bookmarks
|
||||
- `page_image_to_base64(image)` - Convert page image to base64
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from dreader import create_ebook_reader
|
||||
from dreader.html_generator import (
|
||||
generate_library_html,
|
||||
generate_reader_html,
|
||||
generate_toc_overlay
|
||||
)
|
||||
from dreader.book_utils import (
|
||||
scan_book_directory,
|
||||
get_chapter_list,
|
||||
page_image_to_base64
|
||||
)
|
||||
|
||||
# 1. Show library view
|
||||
books_dir = Path('books')
|
||||
books = scan_book_directory(books_dir)
|
||||
library_html = generate_library_html(books)
|
||||
# Pass library_html to HAL for rendering
|
||||
|
||||
# 2. User selects a book
|
||||
selected_book = books[0]
|
||||
reader = create_ebook_reader(page_size=(800, 1000))
|
||||
reader.load_epub(selected_book['path'])
|
||||
|
||||
# 3. Show reader view
|
||||
page_image = reader.get_current_page()
|
||||
page_base64 = page_image_to_base64(page_image)
|
||||
reader_html = generate_reader_html(
|
||||
book_title=reader.book_title,
|
||||
book_author=reader.book_author,
|
||||
page_image_data=page_base64
|
||||
)
|
||||
# Pass reader_html to HAL for rendering
|
||||
|
||||
# 4. User presses "Contents" button - show TOC overlay
|
||||
chapters = get_chapter_list(reader)
|
||||
toc_html = generate_toc_overlay(chapters)
|
||||
# Pass toc_html to HAL for rendering on top of page
|
||||
```
|
||||
|
||||
## HTML Structure
|
||||
|
||||
### Library View
|
||||
|
||||
The library uses an HTML table for grid layout:
|
||||
|
||||
```html
|
||||
<table class="library-grid">
|
||||
<tr>
|
||||
<td class="book-item">
|
||||
<table>
|
||||
<tr><td class="cover-cell"><img src="..."></td></tr>
|
||||
<tr><td class="title-cell">Book Title</td></tr>
|
||||
<tr><td class="author-cell">Author Name</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<!-- More books... -->
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
### Reader View
|
||||
|
||||
The reader view has three sections:
|
||||
|
||||
- Header: Book info + buttons (Library, Contents, Settings)
|
||||
- Page container: Centered book page image
|
||||
- Footer: Navigation buttons (Previous, Next)
|
||||
|
||||
### Overlays
|
||||
|
||||
Overlays use:
|
||||
|
||||
- Semi-transparent background (`rgba(0, 0, 0, 0.7)`)
|
||||
- Centered white panel
|
||||
- Close button
|
||||
- Table-based layout for content
|
||||
|
||||
## Button/Link Interaction
|
||||
|
||||
All interactive elements have:
|
||||
|
||||
- `id` attributes for buttons (e.g., `id="btn-next"`)
|
||||
- `data-*` attributes for dynamic content (e.g., `data-chapter-index="5"`)
|
||||
- CSS classes for styling (e.g., `class="nav-button"`)
|
||||
|
||||
Your HAL should:
|
||||
|
||||
1. Parse the HTML to identify interactive elements
|
||||
2. Map touch/click coordinates to elements
|
||||
3. Call appropriate dreader methods
|
||||
4. Regenerate and render updated HTML
|
||||
|
||||
## Demo
|
||||
|
||||
Run the included demo to see all features:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python examples/html_generation_demo.py
|
||||
```
|
||||
|
||||
This will generate example HTML files in the `output/` directory that you can open in a browser to preview.
|
||||
|
||||
## Integration with HAL
|
||||
|
||||
Your HAL should implement:
|
||||
|
||||
1. **HTML Rendering**: Parse and display HTML strings
|
||||
2. **Touch Input**: Map touch coordinates to HTML elements
|
||||
3. **State Management**: Maintain reader state between interactions
|
||||
4. **Re-rendering**: Update display when state changes
|
||||
|
||||
Example HAL flow:
|
||||
|
||||
```
|
||||
User touches screen
|
||||
↓
|
||||
HAL identifies touched element (e.g., "btn-next")
|
||||
↓
|
||||
HAL calls reader.next_page()
|
||||
↓
|
||||
HAL regenerates reader_html with new page
|
||||
↓
|
||||
HAL renders updated HTML
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
All HTML includes inline CSS for complete styling. The design is:
|
||||
|
||||
- Clean, minimal interface
|
||||
- Dark theme for reader (reduces eye strain)
|
||||
- Large touch targets for buttons
|
||||
- Responsive layout using tables (widely supported)
|
||||
|
||||
## Customization
|
||||
|
||||
To customize the UI:
|
||||
|
||||
1. Edit functions in `dreader/html_generator.py`
|
||||
2. Modify CSS in the `<style>` blocks
|
||||
3. Change layout structure in the HTML templates
|
||||
4. Adjust colors, fonts, spacing as needed
|
||||
|
||||
## Files
|
||||
|
||||
- `dreader/html_generator.py` - HTML generation functions
|
||||
- `dreader/book_utils.py` - Book scanning and utilities
|
||||
- `examples/html_generation_demo.py` - Complete demonstration
|
||||
- `output/` - Generated HTML examples (after running demo)
|
||||
@@ -8,11 +8,9 @@
|
||||
|  | **Documentation Coverage** - Percentage of code with docstrings |
|
||||
|  | **License** - Project licensing information |
|
||||
|
||||
> 📋 **Note**: Badges show results from the commit referenced in the URLs. Red "error" badges indicate build failures for that specific step.
|
||||
|
||||
## Description
|
||||
|
||||
DReader Application is a complete, production-ready ebook reader built on [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It demonstrates how to build a full-featured ebook reader with text highlighting, bookmarks, gesture support, and position persistence.
|
||||
DReader Application is a complete, production-ready ebook reader built on [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It demonstrates how to build a full-featured ebook reader with library browsing, text highlighting, bookmarks, gesture support, overlays, and position persistence.
|
||||
|
||||
This project serves as both a reference implementation and a ready-to-use ereader library for building desktop, web-based, or embedded reading applications.
|
||||
|
||||
@@ -20,11 +18,12 @@ This project serves as both a reference implementation and a ready-to-use ereade
|
||||
|
||||
### Core Reading Features
|
||||
- 📖 **EPUB Support** - Load and render EPUB files with full text extraction
|
||||
- 📚 **Library Management** - Browse and select books from your collection
|
||||
- 📄 **Page Rendering** - Render pages as PIL Images optimized for any display
|
||||
- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation
|
||||
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
||||
- 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC
|
||||
- 📋 **TOC Overlay** - Interactive table of contents overlay with gesture support
|
||||
- 📋 **Unified Overlays** - Navigation (TOC + Bookmarks) and Settings overlays
|
||||
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
||||
|
||||
### Text Interaction
|
||||
@@ -40,6 +39,7 @@ This project serves as both a reference implementation and a ready-to-use ereade
|
||||
- 💾 **Position Persistence** - Stable positions across style changes
|
||||
- ⚡ **Smart Reflow** - Automatic text reflow on font/spacing changes
|
||||
- 🎨 **Custom Styling** - Full control over colors, fonts, and layout
|
||||
- 💾 **Settings Persistence** - Save and restore preferences across sessions
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -156,8 +156,8 @@ reader.get_chapters() # List all chapters
|
||||
reader.get_current_chapter_info()
|
||||
reader.get_reading_progress() # Returns 0.0 to 1.0
|
||||
|
||||
# TOC Overlay
|
||||
overlay_image = reader.open_toc_overlay() # Returns composited image with TOC
|
||||
# Navigation Overlay (unified TOC + Bookmarks)
|
||||
overlay_image = reader.open_navigation_overlay() # Opens with tabs
|
||||
reader.close_overlay()
|
||||
reader.is_overlay_open()
|
||||
```
|
||||
@@ -236,34 +236,84 @@ elif response.action == ActionType.CHAPTER_SELECTED:
|
||||
# - TAP: Select words, activate links, navigate TOC
|
||||
# - LONG_PRESS: Show definitions or context menu
|
||||
# - SWIPE_LEFT/RIGHT: Page navigation
|
||||
# - SWIPE_UP: Open TOC overlay (from bottom 20% of screen)
|
||||
# - SWIPE_DOWN: Close overlay
|
||||
# - SWIPE_UP: Open navigation overlay (from bottom 20% of screen)
|
||||
# - SWIPE_DOWN: Close overlay or open settings (from top 20%)
|
||||
# - PINCH_IN/OUT: Font size adjustment
|
||||
# - DRAG: Text selection
|
||||
```
|
||||
|
||||
### File Operations
|
||||
### Settings Persistence
|
||||
|
||||
```python
|
||||
# Save current page to file
|
||||
reader.render_to_file("current_page.png")
|
||||
from dreader.state import StateManager
|
||||
from pathlib import Path
|
||||
|
||||
# Context manager (auto-saves position on close)
|
||||
with EbookReader(page_size=(800, 1000)) as reader:
|
||||
reader.load_epub("book.epub")
|
||||
# ... use reader ...
|
||||
# Position automatically saved on exit
|
||||
# Initialize state manager
|
||||
state_file = Path.home() / ".config" / "dreader" / "state.json"
|
||||
state_manager = StateManager(state_file=state_file)
|
||||
|
||||
# Load saved state
|
||||
state = state_manager.load_state()
|
||||
|
||||
# Create reader and apply saved settings
|
||||
reader = EbookReader(page_size=(800, 1000))
|
||||
reader.load_epub("mybook.epub")
|
||||
reader.apply_settings(state.settings.to_dict())
|
||||
|
||||
# Settings are automatically saved
|
||||
reader.increase_font_size()
|
||||
state_manager.update_settings(reader.get_current_settings())
|
||||
state_manager.save_state()
|
||||
```
|
||||
|
||||
### Library Management
|
||||
|
||||
```python
|
||||
from dreader.library import LibraryManager
|
||||
|
||||
# Initialize library
|
||||
library = LibraryManager(
|
||||
library_path="/path/to/books",
|
||||
page_size=(800, 1200)
|
||||
)
|
||||
|
||||
# Scan for EPUB files
|
||||
library.scan_library()
|
||||
|
||||
# Render library view
|
||||
library_image = library.render_library()
|
||||
|
||||
# Handle book selection
|
||||
book_path = library.handle_library_tap(x=400, y=300)
|
||||
if book_path:
|
||||
reader.load_epub(book_path)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Check out the `examples/` directory for complete working examples:
|
||||
Check out the [examples/](examples/) directory for complete working examples:
|
||||
|
||||
### Basic Examples
|
||||
- **[simple_ereader_example.py](examples/simple_ereader_example.py)** - Basic ereader usage with EPUB loading and navigation
|
||||
- **[ereader_demo.py](examples/ereader_demo.py)** - Comprehensive demo showcasing all features
|
||||
- **[word_selection_highlighting.py](examples/word_selection_highlighting.py)** - Text selection and highlighting
|
||||
- **[simple_word_highlight.py](examples/simple_word_highlight.py)** - Minimal highlighting example
|
||||
|
||||
### Text Highlighting
|
||||
- **[word_selection_highlighting.py](examples/word_selection_highlighting.py)** - Text selection and highlighting
|
||||
|
||||
### Overlays
|
||||
- **[demo_toc_overlay.py](examples/demo_toc_overlay.py)** - Interactive table of contents overlay
|
||||
- **[navigation_overlay_example.py](examples/navigation_overlay_example.py)** - Unified navigation overlay (TOC + Bookmarks)
|
||||
- **[demo_settings_overlay.py](examples/demo_settings_overlay.py)** - Settings panel with font/spacing controls
|
||||
|
||||
### Library & State
|
||||
- **[library_reading_integration.py](examples/library_reading_integration.py)** - Complete library → reading → resume workflow
|
||||
- **[persistent_settings_example.py](examples/persistent_settings_example.py)** - Save/restore settings across sessions
|
||||
|
||||
### Advanced
|
||||
- **[demo_pagination.py](examples/demo_pagination.py)** - Pagination system demonstration
|
||||
- **[generate_ereader_gifs.py](examples/generate_ereader_gifs.py)** - Generate animated GIF demonstrations
|
||||
- **[generate_library_demo_gif.py](examples/generate_library_demo_gif.py)** - Generate library demo animations
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -279,6 +329,27 @@ dreader.application.EbookReader (High-Level API)
|
||||
└── pyWebLayout.io.readers.epub_reader # EPUB parsing
|
||||
```
|
||||
|
||||
### Component Structure
|
||||
|
||||
```
|
||||
dreader/
|
||||
├── application.py # Main EbookReader class (coordinator)
|
||||
├── managers/ # Specialized management modules
|
||||
│ ├── document.py # Document loading (EPUB/HTML)
|
||||
│ ├── settings.py # Font and spacing controls
|
||||
│ └── highlight_coordinator.py # Text highlighting
|
||||
├── handlers/
|
||||
│ └── gestures.py # Touch event routing
|
||||
├── overlays/ # UI overlay system
|
||||
│ ├── base.py # Base overlay functionality
|
||||
│ ├── navigation.py # TOC and bookmarks overlay
|
||||
│ └── settings.py # Settings overlay
|
||||
├── library.py # Library browsing and book selection
|
||||
├── state.py # Application state persistence
|
||||
├── html_generator.py # HTML generation for overlays
|
||||
└── gesture.py # Gesture definitions and responses
|
||||
```
|
||||
|
||||
### Relationship to pyWebLayout
|
||||
|
||||
**pyWebLayout** is a layout engine library providing low-level primitives:
|
||||
@@ -297,6 +368,30 @@ Think of it like this:
|
||||
- **pyWebLayout** = React (library)
|
||||
- **DReader Application** = Next.js (framework)
|
||||
|
||||
## State Management
|
||||
|
||||
### File Structure
|
||||
```
|
||||
~/.config/dreader/
|
||||
├── state.json # Application state
|
||||
├── covers/ # Cached book covers
|
||||
├── bookmarks/ # Per-book bookmarks
|
||||
├── highlights/ # Per-book highlights
|
||||
└── xray/ # X-Ray data (future)
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
- **Auto-save**: Every 60 seconds
|
||||
- **Immediate save**: On mode change, settings change, shutdown
|
||||
- **Boot behavior**: Resume last book at last position or show library
|
||||
- **Error handling**: Fall back to library if book missing or state corrupt
|
||||
|
||||
### Position Stability
|
||||
- Positions stored by abstract document structure (chapter/block/word indices)
|
||||
- Stable across font size changes, spacing changes, page size changes
|
||||
- Per-book storage using document IDs
|
||||
- Special `__auto_resume__` bookmark for last reading position
|
||||
|
||||
## Use Cases
|
||||
|
||||
- 📱 **Desktop Ereader Applications** - Build native ereader apps with Python
|
||||
@@ -340,6 +435,9 @@ python simple_ereader_example.py /path/to/book.epub
|
||||
# Run comprehensive demo
|
||||
python ereader_demo.py /path/to/book.epub
|
||||
|
||||
# Run library integration demo
|
||||
python library_reading_integration.py /path/to/library/
|
||||
|
||||
# Generate animated GIFs
|
||||
python generate_ereader_gifs.py /path/to/book.epub
|
||||
```
|
||||
@@ -351,6 +449,10 @@ The project includes comprehensive tests covering:
|
||||
- **Application API** - All EbookReader methods and workflows
|
||||
- **System Integration** - Layout manager, bookmarks, and state management
|
||||
- **Highlighting** - Word and selection highlighting with persistence
|
||||
- **Overlays** - Navigation and settings overlay interactions
|
||||
- **Gestures** - Touch event handling and routing
|
||||
- **Boot Recovery** - State persistence and position restoration
|
||||
- **Library** - Book scanning, selection, and metadata
|
||||
- **Edge Cases** - Error handling, boundary conditions, and recovery
|
||||
|
||||
```bash
|
||||
@@ -367,6 +469,79 @@ pytest -v
|
||||
pytest --cov=dreader --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Hardware Integration
|
||||
|
||||
DReader includes complete hardware support for e-ink displays via the **dreader-hal** library.
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
- **Display**: IT8951 e-ink controller (1872×1404)
|
||||
- **Touch**: FT5316 capacitive touch panel
|
||||
- **Buttons**: GPIO buttons (configurable)
|
||||
- **Sensors**: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor
|
||||
|
||||
### Quick Setup on Raspberry Pi
|
||||
|
||||
```bash
|
||||
# 1. Clone and install
|
||||
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
||||
cd dreader-application
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e .
|
||||
./install_hardware_drivers.sh
|
||||
|
||||
# 2. Interactive hardware setup
|
||||
sudo python3 setup_rpi.py
|
||||
|
||||
# 3. Run on hardware
|
||||
python examples/run_on_hardware_config.py
|
||||
```
|
||||
|
||||
### Hardware Configuration
|
||||
|
||||
The repository includes a pre-configured **[hardware_config.json](hardware_config.json)** for the reference hardware:
|
||||
|
||||
- **Buttons**: GPIO 22 (prev), GPIO 27 (next), GPIO 21 (power)
|
||||
- **Display**: 1872×1404 IT8951 e-ink
|
||||
- **I2C Bus**: GPIO 2/3 (touch, sensors, RTC, power)
|
||||
|
||||
See [docs/HARDWARE.md](docs/HARDWARE.md) for complete wiring diagrams and setup instructions.
|
||||
|
||||
### HAL Architecture
|
||||
|
||||
```python
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
# Hardware HAL with GPIO buttons
|
||||
hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0)
|
||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
||||
app = DReaderApplication(config)
|
||||
```
|
||||
|
||||
**Available HAL Implementations:**
|
||||
- **HardwareDisplayHAL** - Real e-ink hardware (IT8951 + dreader-hal)
|
||||
- **PygameDisplayHAL** - Desktop testing with pygame window
|
||||
|
||||
See [docs/HARDWARE.md](docs/HARDWARE.md) for pin assignments and button configuration.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [README.md](README.md) - This file, main project documentation
|
||||
- [docs/HARDWARE.md](docs/HARDWARE.md) - Wiring, configuration, and running on real hardware
|
||||
- [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) - Application requirements specification
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and design details
|
||||
- [docs/HAL_IMPLEMENTATION_SPEC.md](docs/HAL_IMPLEMENTATION_SPEC.md) - Writing a HAL for other hardware
|
||||
|
||||
## Performance
|
||||
|
||||
- **Boot Time**: ~2-3 seconds to resume reading
|
||||
- **Page Turn**: ~50-100ms (depends on page complexity)
|
||||
- **Overlay Open**: ~200-250ms (includes HTML generation and rendering)
|
||||
- **Memory Usage**: ~20-30MB base + 10-50MB per book
|
||||
- **Cache**: Automatic cover image and metadata caching for fast library loading
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! This project demonstrates what's possible with pyWebLayout. If you build something cool or find ways to improve the reader, please share!
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
# DReader Application Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
DReader is a full-featured ebook reader application built on top of [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It provides a complete reading experience with navigation, bookmarks, highlights, and customizable display settings.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### High-Level Component Structure
|
||||
|
||||
```
|
||||
dreader/
|
||||
├── application.py # Main EbookReader class (coordinator)
|
||||
├── managers/ # Specialized management modules
|
||||
│ ├── document.py # Document loading (EPUB/HTML)
|
||||
│ ├── settings.py # Font and spacing controls
|
||||
│ └── highlight_coordinator.py # Text highlighting
|
||||
├── handlers/
|
||||
│ └── gestures.py # Touch event routing
|
||||
├── overlays/ # UI overlay system
|
||||
│ ├── base.py # Base overlay functionality
|
||||
│ ├── navigation.py # TOC and bookmarks overlay
|
||||
│ └── settings.py # Settings overlay
|
||||
├── library.py # Library browsing and book selection
|
||||
├── state.py # Application state persistence
|
||||
├── html_generator.py # HTML generation for overlays
|
||||
└── gesture.py # Gesture definitions and responses
|
||||
```
|
||||
|
||||
### Relationship to pyWebLayout
|
||||
|
||||
**pyWebLayout** provides low-level rendering primitives:
|
||||
- Text layout and rendering algorithms
|
||||
- Document structure and pagination
|
||||
- Query systems for interactive content
|
||||
- Core rendering infrastructure
|
||||
|
||||
**DReader** is an application framework that:
|
||||
- Combines pyWebLayout components into a complete reader
|
||||
- Provides high-level APIs for common ereader tasks
|
||||
- Manages application state (bookmarks, highlights, positions)
|
||||
- Handles business logic for gestures and interactions
|
||||
|
||||
Think of it as:
|
||||
- **pyWebLayout** = React (library)
|
||||
- **DReader** = Next.js (framework)
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. EbookReader (Main Coordinator)
|
||||
|
||||
**Location**: [application.py](../dreader/application.py)
|
||||
|
||||
The central orchestrator that coordinates all subsystems:
|
||||
|
||||
```python
|
||||
class EbookReader:
|
||||
"""Main ebook reader application"""
|
||||
|
||||
# Core dependencies
|
||||
manager: EreaderLayoutManager # pyWebLayout layout engine
|
||||
doc_manager: DocumentManager # Document loading
|
||||
settings_manager: SettingsManager # Display settings
|
||||
highlight_coordinator: HighlightCoordinator # Text highlighting
|
||||
gesture_router: GestureRouter # Gesture handling
|
||||
overlay_manager: OverlayManager # Overlay rendering
|
||||
```
|
||||
|
||||
**Key Responsibilities**:
|
||||
- Document lifecycle (load, close)
|
||||
- Page navigation (next, previous, chapters)
|
||||
- Bookmark management
|
||||
- Position persistence
|
||||
- Settings coordination
|
||||
- Gesture event routing
|
||||
|
||||
### 2. Document Manager
|
||||
|
||||
**Location**: [managers/document.py](../dreader/managers/document.py)
|
||||
|
||||
Handles document loading and metadata extraction.
|
||||
|
||||
**Responsibilities**:
|
||||
- Load EPUB files via pyWebLayout
|
||||
- Extract book metadata (title, author, etc.)
|
||||
- Provide document info to other components
|
||||
|
||||
### 3. Settings Manager
|
||||
|
||||
**Location**: [managers/settings.py](../dreader/managers/settings.py)
|
||||
|
||||
Manages all display settings with persistence.
|
||||
|
||||
**Settings**:
|
||||
- Font scale (adjustable font size)
|
||||
- Line spacing
|
||||
- Inter-block spacing (paragraph spacing)
|
||||
- Word spacing
|
||||
|
||||
**Features**:
|
||||
- Real-time preview in settings overlay
|
||||
- Persistent across sessions
|
||||
- Position preservation when settings change
|
||||
|
||||
### 4. Gesture Router
|
||||
|
||||
**Location**: [handlers/gestures.py](../dreader/handlers/gestures.py)
|
||||
|
||||
Routes touch events to appropriate handlers based on application state.
|
||||
|
||||
**Gesture Types**:
|
||||
- `TAP` - Word selection, link following, overlay interaction
|
||||
- `SWIPE_LEFT` - Next page
|
||||
- `SWIPE_RIGHT` - Previous page
|
||||
- `SWIPE_UP` - Open navigation overlay (from bottom 20%)
|
||||
- `SWIPE_DOWN` - Open settings overlay (from top) or close overlay
|
||||
- `PINCH_IN/OUT` - Font size adjustment
|
||||
- `DRAG` - Text selection (start, move, end)
|
||||
|
||||
**Routing Logic**:
|
||||
```
|
||||
Touch Event → GestureRouter
|
||||
├─ Is overlay open?
|
||||
│ ├─ Yes → Route to overlay handler
|
||||
│ └─ No → Route to reading mode handler
|
||||
└─ Return GestureResponse
|
||||
```
|
||||
|
||||
### 5. Overlay System
|
||||
|
||||
**Location**: [overlays/](../dreader/overlays/)
|
||||
|
||||
The overlay system provides modal UI panels over the reading content.
|
||||
|
||||
#### Overlay Manager
|
||||
|
||||
**Location**: [overlays/base.py](../dreader/overlays/base.py)
|
||||
|
||||
Core overlay rendering and compositing infrastructure.
|
||||
|
||||
**Responsibilities**:
|
||||
- Render overlay HTML to images
|
||||
- Composite overlays over base page
|
||||
- Darken background for modal effect
|
||||
- Handle coordinate translation for interaction
|
||||
- Cache for performance
|
||||
|
||||
#### Navigation Overlay
|
||||
|
||||
**Location**: [overlays/navigation.py](../dreader/overlays/navigation.py)
|
||||
|
||||
Unified overlay with tabbed interface for:
|
||||
- **Contents Tab**: Chapter navigation (TOC)
|
||||
- **Bookmarks Tab**: Saved position management
|
||||
|
||||
**Features**:
|
||||
- Tab switching without closing overlay
|
||||
- Chapter selection with jump
|
||||
- Bookmark selection with jump
|
||||
- Add/delete bookmarks
|
||||
|
||||
#### Settings Overlay
|
||||
|
||||
**Location**: [overlays/settings.py](../dreader/overlays/settings.py)
|
||||
|
||||
Interactive settings panel with real-time preview.
|
||||
|
||||
**Controls**:
|
||||
- Font size: A- / A+ buttons
|
||||
- Line spacing: +/- buttons
|
||||
- Block spacing: +/- buttons
|
||||
- Word spacing: +/- buttons
|
||||
|
||||
**Interaction**: Changes apply immediately, overlay refreshes to show updated values.
|
||||
|
||||
### 6. Library Manager
|
||||
|
||||
**Location**: [library.py](../dreader/library.py)
|
||||
|
||||
Manages the library browsing experience.
|
||||
|
||||
**Features**:
|
||||
- Scan directory for EPUB files
|
||||
- Extract and cache metadata
|
||||
- Render library grid view
|
||||
- Handle book selection via tap
|
||||
- Cache cover images for performance
|
||||
|
||||
**Display**: Renders books in a grid with cover thumbnails and metadata.
|
||||
|
||||
### 7. State Manager
|
||||
|
||||
**Location**: [state.py](../dreader/state.py)
|
||||
|
||||
Persistent application state across sessions.
|
||||
|
||||
**State Structure**:
|
||||
```python
|
||||
class AppState:
|
||||
mode: EreaderMode # LIBRARY or READING
|
||||
overlay: OverlayState # Current overlay type
|
||||
current_book: BookState # Currently open book
|
||||
library: LibraryState # Library scan cache
|
||||
settings: SettingsState # Display settings
|
||||
```
|
||||
|
||||
**Persistence**:
|
||||
- Location: `~/.config/dreader/state.json`
|
||||
- Auto-save every 60 seconds
|
||||
- Immediate save on mode change, settings change, shutdown
|
||||
- Atomic writes for safety
|
||||
|
||||
**Boot Behavior**:
|
||||
- Resume last book at last position
|
||||
- Restore all settings
|
||||
- Fall back to library if book missing
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### Opening an Overlay
|
||||
|
||||
```
|
||||
User Action
|
||||
↓
|
||||
EbookReader.open_navigation_overlay()
|
||||
├─ Get current page (base layer)
|
||||
├─ Get chapters and bookmarks
|
||||
↓
|
||||
OverlayManager.open_navigation_overlay()
|
||||
├─ Generate HTML
|
||||
├─ Render to image (using temp reader)
|
||||
├─ Composite over base page
|
||||
│ ├─ Darken background
|
||||
│ ├─ Add border
|
||||
│ └─ Paste panel at center
|
||||
└─ Cache base page, overlay, offset
|
||||
↓
|
||||
Return composited image
|
||||
```
|
||||
|
||||
### Overlay Interaction
|
||||
|
||||
```
|
||||
User Touch (x, y)
|
||||
↓
|
||||
GestureRouter.handle_touch()
|
||||
├─ Overlay open? YES
|
||||
↓
|
||||
EbookReader._handle_overlay_tap(x, y)
|
||||
↓
|
||||
OverlayManager.query_overlay_pixel(x, y)
|
||||
├─ Translate screen coords to overlay coords
|
||||
├─ Query pyWebLayout for link at position
|
||||
└─ Return link_target (e.g., "chapter:5")
|
||||
↓
|
||||
Parse link_target and execute action:
|
||||
├─ "chapter:N" → jump_to_chapter(N), close overlay
|
||||
├─ "bookmark:name" → load_position(name), close overlay
|
||||
├─ "setting:action" → apply setting, refresh overlay
|
||||
└─ "tab:name" → switch tab, keep overlay open
|
||||
↓
|
||||
Return GestureResponse
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
```
|
||||
Application Running
|
||||
↓
|
||||
StateManager auto-save timer (every 60s)
|
||||
├─ Gather current state
|
||||
├─ Serialize to JSON
|
||||
└─ Atomic write to disk
|
||||
|
||||
OR
|
||||
|
||||
User performs action (page turn, setting change)
|
||||
├─ StateManager.save_state()
|
||||
└─ Immediate write
|
||||
|
||||
Application Shutdown
|
||||
├─ Save position: reader.save_position("__auto_resume__")
|
||||
├─ Stop auto-save
|
||||
└─ Final state.json write
|
||||
```
|
||||
|
||||
### Boot Sequence
|
||||
|
||||
```
|
||||
Application Start
|
||||
↓
|
||||
StateManager.load_state()
|
||||
├─ Read state.json
|
||||
├─ Validate and parse
|
||||
└─ Create AppState object
|
||||
↓
|
||||
Check previous mode:
|
||||
├─ READING mode?
|
||||
│ ├─ Load last book
|
||||
│ ├─ Apply saved settings
|
||||
│ └─ Restore position ("__auto_resume__")
|
||||
│
|
||||
└─ LIBRARY mode?
|
||||
└─ Show library grid
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Application State Files
|
||||
|
||||
```
|
||||
~/.config/dreader/
|
||||
├── state.json # Application state
|
||||
├── covers/ # Cached book covers
|
||||
│ └── {book_id}.png
|
||||
├── bookmarks/ # Per-book bookmarks
|
||||
│ └── {document_id}_{bookmark_name}.json
|
||||
└── highlights/ # Per-book highlights
|
||||
└── {document_id}_highlights.json
|
||||
```
|
||||
|
||||
### Bookmark Format
|
||||
|
||||
Each book's position is stored separately using document ID:
|
||||
```json
|
||||
{
|
||||
"document_id": "book123",
|
||||
"bookmark_name": "__auto_resume__",
|
||||
"position": {
|
||||
"offset": 1234,
|
||||
"chapter": 5
|
||||
},
|
||||
"timestamp": "2025-11-09T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Gesture Handling
|
||||
|
||||
### Gesture Priority and Routing
|
||||
|
||||
```
|
||||
Touch Event
|
||||
↓
|
||||
Is overlay open?
|
||||
├─ YES → Overlay Mode
|
||||
│ ├─ TAP → Handle overlay interaction
|
||||
│ ├─ SWIPE_DOWN → Close overlay
|
||||
│ └─ Other → Ignore (modal behavior)
|
||||
│
|
||||
└─ NO → Reading Mode
|
||||
├─ TAP
|
||||
│ ├─ On link → Follow link
|
||||
│ ├─ On word → Select word
|
||||
│ ├─ Left edge → Previous page
|
||||
│ └─ Right edge → Next page
|
||||
│
|
||||
├─ SWIPE
|
||||
│ ├─ LEFT → Next page
|
||||
│ ├─ RIGHT → Previous page
|
||||
│ ├─ UP (from bottom 20%) → Open navigation
|
||||
│ └─ DOWN (from top 20%) → Open settings
|
||||
│
|
||||
├─ PINCH
|
||||
│ ├─ IN → Decrease font size
|
||||
│ └─ OUT → Increase font size
|
||||
│
|
||||
└─ DRAG
|
||||
├─ START → Begin text selection
|
||||
├─ MOVE → Extend selection
|
||||
└─ END → Complete selection
|
||||
```
|
||||
|
||||
### Response Types
|
||||
|
||||
```python
|
||||
class ActionType(Enum):
|
||||
NONE = "none"
|
||||
PAGE_TURN = "page_turn"
|
||||
WORD_SELECTED = "word_selected"
|
||||
LINK_FOLLOWED = "link_followed"
|
||||
CHAPTER_SELECTED = "chapter_selected"
|
||||
BOOKMARK_SELECTED = "bookmark_selected"
|
||||
SETTING_CHANGED = "setting_changed"
|
||||
OVERLAY_OPENED = "overlay_opened"
|
||||
OVERLAY_CLOSED = "overlay_closed"
|
||||
TAB_SWITCHED = "tab_switched"
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Rendering Performance
|
||||
|
||||
- **Page Turn**: ~50-100ms (depends on page complexity)
|
||||
- **Overlay Open**: ~200-250ms (includes HTML generation and rendering)
|
||||
- **Tab Switch**: ~125ms (uses cached base page)
|
||||
- **Setting Change**: ~150ms (re-render with new settings)
|
||||
- **Tap Interaction**: ~5-10ms (coordinate query)
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Base Application**: ~20-30MB
|
||||
- **Per Book**: ~10-50MB (depends on images)
|
||||
- **Overlay Cache**: ~5-10MB
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Caching**:
|
||||
- Base page cached during overlay display
|
||||
- Overlay panel cached for tab switching
|
||||
- Cover images cached to disk
|
||||
- Metadata cached between sessions
|
||||
|
||||
2. **Lazy Loading**:
|
||||
- Library covers loaded on-demand
|
||||
- Book content loaded only when opened
|
||||
- Overlays rendered only when needed
|
||||
|
||||
3. **Efficient Updates**:
|
||||
- Tab switching reuses base page
|
||||
- Setting changes use incremental rendering
|
||||
- Position saves are debounced
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Overlays
|
||||
|
||||
To add a new overlay type:
|
||||
|
||||
1. Define new `OverlayState` enum value in [state.py](../dreader/state.py#L27-L33)
|
||||
2. Create HTML generator in [html_generator.py](../dreader/html_generator.py)
|
||||
3. Add overlay class in `overlays/` directory
|
||||
4. Implement open/close methods in [overlay manager](../dreader/overlays/base.py)
|
||||
5. Add gesture handling in [application.py](../dreader/application.py)
|
||||
|
||||
### Custom Gesture Handlers
|
||||
|
||||
To add custom gestures:
|
||||
|
||||
1. Define gesture type in [gesture.py](../dreader/gesture.py)
|
||||
2. Add handler in [gestures.py](../dreader/handlers/gestures.py)
|
||||
3. Define action type for response
|
||||
4. Update gesture router logic
|
||||
|
||||
### HAL Integration
|
||||
|
||||
To integrate with hardware:
|
||||
|
||||
Create a display abstraction layer implementing:
|
||||
```python
|
||||
class DisplayHAL(ABC):
|
||||
@abstractmethod
|
||||
def show_image(self, image: Image.Image):
|
||||
"""Display image on hardware"""
|
||||
|
||||
@abstractmethod
|
||||
def get_touch_events(self) -> Iterator[TouchEvent]:
|
||||
"""Get touch input from hardware"""
|
||||
|
||||
@abstractmethod
|
||||
def set_brightness(self, level: int):
|
||||
"""Control display brightness"""
|
||||
```
|
||||
|
||||
Examples:
|
||||
- **E-ink**: IT8951, Remarkable device SDK
|
||||
- **Desktop**: pygame, tkinter
|
||||
- **Web**: Flask + HTML canvas
|
||||
- **Qt**: QPixmap + QTouchEvent
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- State serialization and persistence
|
||||
- Gesture routing logic
|
||||
- Coordinate translation
|
||||
- HTML generation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Mode transitions (LIBRARY ↔ READING)
|
||||
- Overlay lifecycle (open → interact → close)
|
||||
- Boot recovery and resume
|
||||
- Settings persistence
|
||||
|
||||
### Example-Based Testing
|
||||
|
||||
Working examples demonstrate full integration:
|
||||
- [simple_ereader_example.py](../examples/simple_ereader_example.py)
|
||||
- [library_reading_integration.py](../examples/library_reading_integration.py)
|
||||
- [navigation_overlay_example.py](../examples/navigation_overlay_example.py)
|
||||
- [demo_settings_overlay.py](../examples/demo_settings_overlay.py)
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### Component-Based Architecture
|
||||
|
||||
- **Managers**: Single-responsibility modules for specific tasks
|
||||
- **Handlers**: Event routing and processing
|
||||
- **Overlays**: Self-contained UI components
|
||||
|
||||
### Delegation Over Inheritance
|
||||
|
||||
- EbookReader delegates to specialized managers
|
||||
- No deep inheritance hierarchies
|
||||
- Composition for flexibility
|
||||
|
||||
### State Machine Pattern
|
||||
|
||||
- Clear state transitions (modes, overlays)
|
||||
- State persistence for resume
|
||||
- Predictable behavior
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
- Touch events drive all interactions
|
||||
- Response objects communicate results
|
||||
- Decoupled components
|
||||
|
||||
## Future Architecture Considerations
|
||||
|
||||
### Sub-Application Pattern
|
||||
|
||||
Current overlay handling uses a monolithic approach. Future refactoring could extract overlays into sub-applications:
|
||||
|
||||
```python
|
||||
class OverlaySubApplication(ABC):
|
||||
def open(self, context: OverlayContext) -> Image.Image: ...
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse: ...
|
||||
def close(self) -> Image.Image: ...
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Self-contained overlay logic
|
||||
- Easier testing
|
||||
- Plugin support
|
||||
- Composable overlays
|
||||
|
||||
### Plugin System
|
||||
|
||||
Enable third-party extensions:
|
||||
- Custom overlay types
|
||||
- Additional gestures
|
||||
- Export formats
|
||||
- Cloud sync providers
|
||||
|
||||
## References
|
||||
|
||||
- [pyWebLayout Documentation](https://gitea.tourolle.paris/dtourolle/pyWebLayout)
|
||||
- [REQUIREMENTS.md](REQUIREMENTS.md) - Detailed feature specifications
|
||||
- [README.md](../README.md) - User-facing documentation
|
||||
- [examples/](../examples/) - Working code examples
|
||||
@@ -0,0 +1,853 @@
|
||||
# DReader Hardware Guide
|
||||
|
||||
Everything needed to build, wire, configure and run DReader on real e-ink hardware.
|
||||
|
||||
This guide covers the reference device: a Raspberry Pi driving an IT8951 e-ink
|
||||
controller with an FT5316 touch panel, optional sensors and three physical buttons.
|
||||
If you are *implementing a HAL for different hardware*, see
|
||||
[HAL_IMPLEMENTATION_SPEC.md](HAL_IMPLEMENTATION_SPEC.md) instead.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [1. Hardware Requirements](#1-hardware-requirements)
|
||||
- [2. Wiring](#2-wiring)
|
||||
- [3. Software Installation](#3-software-installation)
|
||||
- [4. Configuration](#4-configuration)
|
||||
- [5. Running DReader](#5-running-dreader)
|
||||
- [6. Input Reference](#6-input-reference)
|
||||
- [7. Accelerometer Page Flipping](#7-accelerometer-page-flipping)
|
||||
- [8. Verifying Hardware](#8-verifying-hardware)
|
||||
- [9. Troubleshooting](#9-troubleshooting)
|
||||
- [10. Performance Notes](#10-performance-notes)
|
||||
- [Appendix: Programmatic Usage](#appendix-programmatic-usage)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
On a Raspberry Pi with the hardware already wired:
|
||||
|
||||
```bash
|
||||
# 1. Clone and set up
|
||||
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
||||
cd dreader-application
|
||||
git submodule update --init --recursive
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 2. Install the application and hardware drivers
|
||||
pip install -e .
|
||||
./install_hardware_drivers.sh
|
||||
|
||||
# 3. Interactive setup — detects hardware, writes hardware_config.json
|
||||
sudo python3 setup_rpi.py
|
||||
|
||||
# 4. Run
|
||||
python examples/run_on_hardware_config.py
|
||||
```
|
||||
|
||||
> ⚠️ **Before first run, check your display's VCOM voltage.** See
|
||||
> [VCOM Voltage](#vcom-voltage) — an incorrect value can damage the panel.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hardware Requirements
|
||||
|
||||
### Required
|
||||
|
||||
| Component | Notes |
|
||||
|-----------|-------|
|
||||
| **Raspberry Pi** (or compatible SBC) | 512MB RAM minimum |
|
||||
| **IT8951 E-ink Display Controller** | 1872 × 1404, SPI |
|
||||
| **FT5316 Capacitive Touch Panel** | I2C, address `0x38` |
|
||||
|
||||
### Optional
|
||||
|
||||
| Component | Purpose | I2C Address |
|
||||
|-----------|---------|-------------|
|
||||
| **BMA400 Accelerometer** | Auto-rotation and tilt page-flipping | `0x14` (or `0x15`) |
|
||||
| **PCF8523 RTC** | Timekeeping with battery backup | `0x68` |
|
||||
| **INA219 Power Monitor** | Battery level monitoring | `0x40` |
|
||||
| **Momentary pushbuttons** ×3 | Page turns and power off | — |
|
||||
|
||||
### Power Requirements
|
||||
|
||||
- **Input:** 5V via USB-C or GPIO header
|
||||
- **Display:** ~3.3V, peak 500mA during refresh
|
||||
- **Touch panel:** 3.3V, ~20mA
|
||||
- **Total (active):** ~1–2W
|
||||
- **Total (sleep):** ~50–100mW
|
||||
|
||||
---
|
||||
|
||||
## 2. Wiring
|
||||
|
||||
### Pin Assignment Summary
|
||||
|
||||
All GPIO numbers in this guide use **BCM numbering**, not physical pin numbers.
|
||||
|
||||
| GPIO | Physical Pin | Function |
|
||||
|------|--------------|----------|
|
||||
| 2 | 3 | I2C1 SDA — touch, accelerometer, RTC, power monitor |
|
||||
| 3 | 5 | I2C1 SCL — all I2C devices |
|
||||
| 8 | 24 | SPI0 CE0 — display chip select |
|
||||
| 9 | 21 | SPI0 MISO |
|
||||
| 10 | 19 | SPI0 MOSI |
|
||||
| 11 | 23 | SPI0 SCLK |
|
||||
| 17 | 11 | Display RST |
|
||||
| 21 | 40 | Power off button |
|
||||
| 22 | 15 | Previous page button |
|
||||
| 24 | 18 | Display HRDY |
|
||||
| 27 | 13 | Next page button |
|
||||
|
||||
### IT8951 E-ink Display (SPI)
|
||||
|
||||
| IT8951 Pin | Raspberry Pi | Description |
|
||||
|------------|--------------|-------------|
|
||||
| VCC | 3.3V (Pin 1) | Power supply |
|
||||
| GND | GND (Pin 6) | Ground |
|
||||
| MISO | GPIO 9 (Pin 21) | SPI MISO |
|
||||
| MOSI | GPIO 10 (Pin 19) | SPI MOSI |
|
||||
| SCK | GPIO 11 (Pin 23) | SPI Clock |
|
||||
| CS | GPIO 8 (Pin 24) | SPI Chip Select |
|
||||
| RST | GPIO 17 (Pin 11) | Reset |
|
||||
| HRDY | GPIO 24 (Pin 18) | Ready signal |
|
||||
|
||||
### I2C Devices
|
||||
|
||||
All I2C devices share SDA (GPIO 2, Pin 3) and SCL (GPIO 3, Pin 5), plus 3.3V and GND.
|
||||
Each must have a unique address.
|
||||
|
||||
| Device | Extra connections |
|
||||
|--------|-------------------|
|
||||
| **FT5316 Touch Panel** | INT is optional and unused in the reference build — see note below |
|
||||
| **BMA400 Accelerometer** | None. Confirm the address on your module (`0x14` or `0x15`) |
|
||||
| **PCF8523 RTC** | BAT → CR2032 backup battery |
|
||||
| **INA219 Power Monitor** | VIN+ → battery positive; VIN- → through shunt resistor to load |
|
||||
|
||||
> **Note on the FT5316 INT pin:** some wiring guides route it to GPIO 27, which
|
||||
> collides with the next-page button on this device. The reference build leaves INT
|
||||
> unconnected and polls the touch panel instead. If you want to use INT, move it to a
|
||||
> free GPIO (see [safe pins](#choosing-gpio-pins)) or relocate the next-page button.
|
||||
|
||||
### GPIO Buttons
|
||||
|
||||
The reference device has three buttons:
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ │
|
||||
│ [Power Off] │ ← Side button (GPIO 21)
|
||||
│ │
|
||||
│ E-INK │
|
||||
│ DISPLAY │
|
||||
│ 1872x1404 │
|
||||
│ │
|
||||
│ [Prev] [Next] │ ← Bottom edge (GPIO 22, GPIO 27)
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
| GPIO | Function | Gesture Generated | Wiring |
|
||||
|------|----------|-------------------|--------|
|
||||
| 22 | Previous Page | `swipe_right` | GPIO → button → **GND**, internal pull-up, active LOW |
|
||||
| 27 | Next Page | `swipe_left` | GPIO → button → **GND**, internal pull-up, active LOW |
|
||||
| 21 | Power Off (long press) | `long_press` | GPIO → button → **3.3V**, pull-up disabled, active HIGH |
|
||||
|
||||
> ⚠️ **The power button is wired differently from the page buttons.** Prev/Next pull
|
||||
> LOW to GND; the power button pulls HIGH when pressed and sets `"pull_up": false` in
|
||||
> the config. Wiring it to GND like the others will make it appear permanently pressed.
|
||||
|
||||
Active-low page button wiring:
|
||||
|
||||
```
|
||||
+3.3V
|
||||
|
|
||||
R (internal pull-up)
|
||||
|
|
||||
GPIO --|------ Button ------ GND
|
||||
|
|
||||
(to BCM2835)
|
||||
```
|
||||
|
||||
Use momentary pushbuttons (normally open) rated for at least 10,000 cycles. Tactile
|
||||
switches give the best feedback. Keep wires short to reduce noise, use stranded wire
|
||||
for flexibility, and add strain relief at the connection points.
|
||||
|
||||
### Physical Layout
|
||||
|
||||
```
|
||||
Raspberry Pi GPIO Header (BCM Numbering)
|
||||
|
||||
3V3 (1) (2) 5V
|
||||
GPIO2 (3) (4) 5V ← I2C1 SDA (touch, sensors)
|
||||
GPIO3 (5) (6) GND ← I2C1 SCL
|
||||
GPIO4 (7) (8) GPIO14
|
||||
GND (9) (10) GPIO15
|
||||
GPIO17 (11) (12) GPIO18 ← Display RST
|
||||
GPIO27 (13) (14) GND ← Next page button
|
||||
GPIO22 (15) (16) GPIO23 ← Previous page button
|
||||
3V3 (17) (18) GPIO24 ← Display HRDY
|
||||
GPIO10 (19) (20) GND ← SPI0 MOSI
|
||||
GPIO9 (21) (22) GPIO25 ← SPI0 MISO
|
||||
GPIO11 (23) (24) GPIO8 ← SPI0 SCLK, CE0
|
||||
GND (25) (26) GPIO7
|
||||
GPIO0 (27) (28) GPIO1
|
||||
GPIO5 (29) (30) GND
|
||||
GPIO6 (31) (32) GPIO12
|
||||
GPIO13 (33) (34) GND
|
||||
GPIO19 (35) (36) GPIO16
|
||||
GPIO26 (37) (38) GPIO20
|
||||
GND (39) (40) GPIO21 ← Power off button
|
||||
```
|
||||
|
||||
### Choosing GPIO Pins
|
||||
|
||||
If you are remapping buttons:
|
||||
|
||||
**Safe for buttons:** GPIO 5, 6, 12, 13, 16, 18, 19, 20, 21, 22, 23, 25, 26, 27
|
||||
|
||||
**Avoid:**
|
||||
|
||||
| Pins | Reserved for |
|
||||
|------|--------------|
|
||||
| GPIO 0, 1 | ID EEPROM |
|
||||
| GPIO 2, 3 | I2C — touch and sensors |
|
||||
| GPIO 7–11 | SPI — e-ink display |
|
||||
| GPIO 14, 15 | UART — serial console |
|
||||
| GPIO 17, 24 | Display RST and HRDY on this device |
|
||||
|
||||
---
|
||||
|
||||
## 3. Software Installation
|
||||
|
||||
### 1. System Dependencies
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3-dev python3-pip python3-venv
|
||||
sudo apt install -y i2c-tools python3-smbus
|
||||
|
||||
# Enable I2C and SPI
|
||||
sudo raspi-config
|
||||
# Navigate to: Interface Options -> Enable I2C and SPI
|
||||
```
|
||||
|
||||
### 2. Clone and Set Up the Application
|
||||
|
||||
```bash
|
||||
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
||||
cd dreader-application
|
||||
|
||||
# dreader-hal is a submodule
|
||||
git submodule update --init --recursive
|
||||
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 3. Install dreader-hal and Drivers
|
||||
|
||||
The simplest route is the bundled script:
|
||||
|
||||
```bash
|
||||
./install_hardware_drivers.sh
|
||||
```
|
||||
|
||||
To do it by hand — `dreader-hal` keeps its driver dependencies in its own
|
||||
`external/` directory:
|
||||
|
||||
```bash
|
||||
pip install -e external/dreader-hal
|
||||
|
||||
cd external/dreader-hal/external
|
||||
pip install -e IT8951
|
||||
pip install -e PyFTtxx6
|
||||
pip install -e PyBMA400
|
||||
pip install -e PyPCF8523
|
||||
pip install -e pi_ina219
|
||||
cd ../../..
|
||||
```
|
||||
|
||||
### 4. Raspberry Pi GPIO
|
||||
|
||||
```bash
|
||||
pip install RPi.GPIO spidev
|
||||
```
|
||||
|
||||
### 5. Permissions
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G spi,i2c,gpio $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
DReader uses two configuration files:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `hardware_config.json` | Display, buttons, sensors, application defaults |
|
||||
| `accelerometer_config.json` | Tilt calibration — see [section 7](#7-accelerometer-page-flipping) |
|
||||
|
||||
### Generating the Config
|
||||
|
||||
The interactive setup script detects your hardware and writes `hardware_config.json`:
|
||||
|
||||
```bash
|
||||
sudo python3 setup_rpi.py
|
||||
```
|
||||
|
||||
It will detect connected I2C devices, enable I2C/SPI if needed, configure the GPIO
|
||||
button mapping and set the VCOM voltage.
|
||||
|
||||
### VCOM Voltage
|
||||
|
||||
⚠️ **CRITICAL**: Every e-ink panel has a unique VCOM voltage printed on a label,
|
||||
usually on the ribbon cable or the back of the panel.
|
||||
|
||||
- Read the value from your display's label (e.g. `VCOM = -2.06V`)
|
||||
- Set it as `display.vcom` in the config, or pass `--vcom -2.06` on the command line
|
||||
- **Using the wrong VCOM can damage the display**
|
||||
|
||||
### hardware_config.json Reference
|
||||
|
||||
```json
|
||||
{
|
||||
"display": {
|
||||
"width": 1872,
|
||||
"height": 1404,
|
||||
"vcom": -1.7,
|
||||
"spi_hz": 24000000,
|
||||
"rotate": "CW",
|
||||
"auto_sleep": true
|
||||
},
|
||||
|
||||
"gpio_buttons": {
|
||||
"enabled": true,
|
||||
"pull_up": true,
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": [
|
||||
{"name": "prev_page", "gpio": 22, "gesture": "swipe_right"},
|
||||
{"name": "next_page", "gpio": 27, "gesture": "swipe_left"},
|
||||
{"name": "power_off", "gpio": 21, "gesture": "long_press", "pull_up": false}
|
||||
]
|
||||
},
|
||||
|
||||
"accelerometer": {
|
||||
"enabled": true,
|
||||
"tilt_enabled": false,
|
||||
"orientation_enabled": true,
|
||||
"calibration_file": "accelerometer_config.json"
|
||||
},
|
||||
|
||||
"rtc": {"enabled": true},
|
||||
|
||||
"power_monitor": {
|
||||
"enabled": true,
|
||||
"shunt_ohms": 0.1,
|
||||
"battery_capacity_mah": 3000,
|
||||
"low_battery_threshold": 20.0,
|
||||
"show_battery_interval": 100
|
||||
},
|
||||
|
||||
"application": {
|
||||
"library_path": "/home/pi/Books",
|
||||
"auto_save_interval": 60,
|
||||
"force_library_mode": false,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `gpio_buttons`
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `enabled` | bool | Enable or disable all GPIO buttons |
|
||||
| `pull_up` | bool | Default pull-up for all buttons; override per button |
|
||||
| `bounce_time_ms` | int | Debounce time in milliseconds (default 200) |
|
||||
| `buttons` | array | Button definitions |
|
||||
|
||||
Each button takes:
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `name` | string | Unique identifier |
|
||||
| `gpio` | int | BCM pin number |
|
||||
| `gesture` | string | Gesture emitted on press — see [gesture table](#gestures) |
|
||||
| `pull_up` | bool | Optional per-button override of the group default |
|
||||
| `description` | string | Optional human-readable note |
|
||||
|
||||
Buttons can be mapped to *any* gesture, so custom layouts are just config. For example,
|
||||
a four-button reading layout:
|
||||
|
||||
```json
|
||||
{
|
||||
"gpio_buttons": {
|
||||
"enabled": true,
|
||||
"buttons": [
|
||||
{"name": "next", "gpio": 27, "gesture": "swipe_left"},
|
||||
{"name": "prev", "gpio": 22, "gesture": "swipe_right"},
|
||||
{"name": "zoom_in", "gpio": 25, "gesture": "pinch_out"},
|
||||
{"name": "zoom_out", "gpio": 23, "gesture": "pinch_in"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Button handling lives in [dreader/gpio_buttons.py](../dreader/gpio_buttons.py).
|
||||
|
||||
---
|
||||
|
||||
## 5. Running DReader
|
||||
|
||||
### With a Config File (Recommended)
|
||||
|
||||
```bash
|
||||
python examples/run_on_hardware_config.py
|
||||
```
|
||||
|
||||
### Direct Command Line
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python examples/run_on_hardware.py /path/to/books --vcom -2.06
|
||||
```
|
||||
|
||||
### Without Hardware (Virtual Display)
|
||||
|
||||
Test the integration on a development machine — this opens a Tkinter window
|
||||
simulating the e-ink panel:
|
||||
|
||||
```bash
|
||||
python examples/run_on_hardware.py /path/to/books \
|
||||
--virtual --no-orientation --no-rtc --no-power
|
||||
```
|
||||
|
||||
### Common Options
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `--vcom <volts>` | Display VCOM voltage (**check your label**) |
|
||||
| `--virtual` | Tkinter virtual display instead of real hardware |
|
||||
| `--no-orientation` | Disable the accelerometer |
|
||||
| `--no-rtc` | Disable the real-time clock |
|
||||
| `--no-power` | Disable the battery monitor |
|
||||
| `--show-battery` | Print battery level periodically |
|
||||
| `--battery-capacity <mAh>` | Set battery capacity (default 3000) |
|
||||
| `--force-library` | Always start in library mode, ignoring saved state |
|
||||
| `--verbose` | Verbose debug logging |
|
||||
|
||||
Full reference: `python examples/run_on_hardware.py --help`
|
||||
|
||||
---
|
||||
|
||||
## 6. Input Reference
|
||||
|
||||
### Gestures
|
||||
|
||||
Every input source — touch, buttons, accelerometer — is normalised into the same
|
||||
gesture stream, so any source can trigger any action.
|
||||
|
||||
| Gesture | Default Action |
|
||||
|---------|----------------|
|
||||
| `swipe_left` | Next page |
|
||||
| `swipe_right` | Previous page |
|
||||
| `swipe_up` (from bottom) | Open navigation/TOC overlay |
|
||||
| `swipe_down` (from top) | Open settings overlay |
|
||||
| `tap` | Select book, word, or link |
|
||||
| `long_press` | Word definition / context menu |
|
||||
| `pinch_in` | Decrease font size |
|
||||
| `pinch_out` | Increase font size |
|
||||
| `tilt_forward` | Next page — see [section 7](#7-accelerometer-page-flipping) |
|
||||
| `tilt_backward` | Previous page — see [section 7](#7-accelerometer-page-flipping) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Accelerometer Page Flipping
|
||||
|
||||
The BMA400 can detect device tilt and turn pages hands-free — useful while eating,
|
||||
holding the device one-handed, when it is mounted on a stand, or for accessibility.
|
||||
|
||||
> Tilt is **off by default** (`accelerometer.tilt_enabled: false`). Enable it in
|
||||
> `hardware_config.json` after calibrating.
|
||||
|
||||
### Calibration
|
||||
|
||||
Calibration establishes which direction is "up" for your device:
|
||||
|
||||
```bash
|
||||
python examples/calibrate_accelerometer.py
|
||||
```
|
||||
|
||||
1. The display shows an arrow pointing in the direction of gravity
|
||||
2. Rotate the device until the arrow points up
|
||||
3. Tap the screen to save
|
||||
4. Calibration is written to `accelerometer_config.json`
|
||||
|
||||
### Calibration File Format
|
||||
|
||||
```json
|
||||
{
|
||||
"up_vector": {"x": 0.0, "y": 9.8, "z": 0.0},
|
||||
"tilt_threshold": 0.3,
|
||||
"debounce_time": 0.5
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `up_vector` | Gravity vector (m/s²) when the device is upright |
|
||||
| `tilt_threshold` | Tilt angle threshold in radians |
|
||||
| `debounce_time` | Minimum seconds between gestures |
|
||||
|
||||
**Tuning `tilt_threshold`:**
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| 0.1 rad (~6°) | Very sensitive — small tilts turn pages |
|
||||
| 0.3 rad (~17°) | Default |
|
||||
| 0.5 rad (~29°) | Requires a deliberate, larger tilt |
|
||||
|
||||
**Tuning `debounce_time`:**
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| 0.2s | Fast — flip several pages quickly |
|
||||
| 0.5s | Default — prevents accidental double-flips |
|
||||
| 1.0s | Slow — requires a pause between flips |
|
||||
|
||||
### How Tilt Detection Works
|
||||
|
||||
1. **Read** the accelerometer for (x, y, z) acceleration in m/s²
|
||||
2. **Normalise** both the current gravity vector and the calibrated up vector
|
||||
3. **Compute the tilt angle** by projecting gravity onto the plane perpendicular to
|
||||
the up vector
|
||||
4. **Compare** against `tilt_threshold`
|
||||
5. **Determine direction** from the sign of the perpendicular y-component
|
||||
6. **Debounce** to suppress repeat triggers
|
||||
|
||||
Given a calibrated up vector `U = (ux, uy, uz)` and current gravity `G = (gx, gy, gz)`:
|
||||
|
||||
```python
|
||||
# Component of G along U
|
||||
dot = gx*ux + gy*uy + gz*uz
|
||||
|
||||
# Perpendicular component
|
||||
perp = G - dot*U
|
||||
perp_magnitude = |perp|
|
||||
|
||||
# Tilt angle
|
||||
angle = atan2(perp_magnitude, |dot|)
|
||||
|
||||
# Direction
|
||||
if perp_y > 0:
|
||||
gesture = TILT_FORWARD # next page
|
||||
else:
|
||||
gesture = TILT_BACKWARD # previous page
|
||||
```
|
||||
|
||||
The implementation lives in [dreader/hal_hardware.py](../dreader/hal_hardware.py)
|
||||
(`get_tilt_gesture`), with the gesture types defined in
|
||||
[dreader/gesture.py](../dreader/gesture.py) and handled in
|
||||
[dreader/handlers/gestures.py](../dreader/handlers/gestures.py).
|
||||
|
||||
### Using Tilt in Your Own Code
|
||||
|
||||
```python
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
enable_orientation=True, # required for tilt
|
||||
)
|
||||
await hal.initialize()
|
||||
|
||||
if hal.load_accelerometer_calibration("accelerometer_config.json"):
|
||||
print("Accelerometer calibrated!")
|
||||
else:
|
||||
print("No calibration found - tilt gestures disabled")
|
||||
```
|
||||
|
||||
**Unified event loop (recommended)** — `get_event()` polls both touch and
|
||||
accelerometer, prioritising touch:
|
||||
|
||||
```python
|
||||
while running:
|
||||
event = await hal.get_event()
|
||||
if event:
|
||||
response = gesture_router.handle_touch(event)
|
||||
await asyncio.sleep(0.01)
|
||||
```
|
||||
|
||||
**Separate polling** — more control over each source:
|
||||
|
||||
```python
|
||||
while running:
|
||||
touch_event = await hal.get_touch_event()
|
||||
tilt_event = await hal.get_tilt_gesture() # None if uncalibrated
|
||||
|
||||
if touch_event:
|
||||
response = gesture_router.handle_touch(touch_event)
|
||||
if tilt_event:
|
||||
response = gesture_router.handle_touch(tilt_event)
|
||||
|
||||
await asyncio.sleep(0.05) # ~20Hz
|
||||
```
|
||||
|
||||
### Tilt API Reference
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `load_accelerometer_calibration(config_path="accelerometer_config.json") -> bool` | Load calibration from JSON. Returns `True` on success. |
|
||||
| `async get_event() -> Optional[TouchEvent]` | **Recommended.** Next event from any input source; touch takes priority over tilt. `None` if nothing pending. |
|
||||
| `async get_tilt_gesture() -> Optional[TouchEvent]` | Poll the accelerometer only. Returns a `TILT_FORWARD`/`TILT_BACKWARD` event, or `None` if there is no tilt, no calibration, or the debounce window is still open. |
|
||||
|
||||
### Demos and Tests
|
||||
|
||||
```bash
|
||||
# Simple demo using the unified get_event() API
|
||||
python examples/demo_accelerometer_simple.py ~/Books/mybook.epub
|
||||
|
||||
# Full-featured demo with separate polling
|
||||
python examples/demo_accelerometer_page_flip.py ~/Books/mybook.epub
|
||||
|
||||
# Tests
|
||||
python -m pytest tests/test_accelerometer_gestures.py -v
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Orientation lock** — tilt detection assumes a fixed device orientation, so
|
||||
auto-rotation can interfere with it.
|
||||
2. **Movement** — walking may cause false positives. Raise the threshold or disable
|
||||
tilt while moving.
|
||||
3. **Calibration drift** — the accelerometer drifts over time; re-calibrate periodically.
|
||||
4. **Simplified direction heuristic** — complex orientations may be misread.
|
||||
5. **Single axis** — only tilt in one plane is detected; left/right tilts are not
|
||||
distinguished.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verifying Hardware
|
||||
|
||||
### Check I2C Devices
|
||||
|
||||
```bash
|
||||
i2cdetect -y 1
|
||||
```
|
||||
|
||||
Expected on a fully-populated device:
|
||||
|
||||
```
|
||||
0 1 2 3 4 5 6 7 8 9 a b c d e f
|
||||
00: -- -- -- -- -- -- -- -- -- -- -- -- --
|
||||
10: -- -- -- -- 14 -- -- -- -- -- -- -- -- -- -- -- ← BMA400
|
||||
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
|
||||
30: -- -- -- -- -- -- -- -- 38 -- -- -- -- -- -- -- ← FT5316
|
||||
40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ← INA219
|
||||
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
|
||||
60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- -- ← PCF8523
|
||||
70: -- -- -- -- -- -- -- --
|
||||
```
|
||||
|
||||
### Check SPI
|
||||
|
||||
```bash
|
||||
ls /dev/spi*
|
||||
# Should show: /dev/spidev0.0 /dev/spidev0.1
|
||||
```
|
||||
|
||||
### Check Buttons
|
||||
|
||||
```bash
|
||||
sudo apt install gpiod
|
||||
|
||||
gpioget gpiochip0 22 # Previous page — 1 idle, 0 when pressed
|
||||
gpioget gpiochip0 27 # Next page — 1 idle, 0 when pressed
|
||||
gpioget gpiochip0 21 # Power off — 0 idle, 1 when pressed (active high)
|
||||
```
|
||||
|
||||
Or watch the application logs:
|
||||
|
||||
```bash
|
||||
python examples/run_on_hardware_config.py --verbose
|
||||
```
|
||||
|
||||
Pressing a button should log something like:
|
||||
|
||||
```
|
||||
Button pressed: next_page (GPIO 27)
|
||||
Button event queued: next_page -> swipe_left
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
### Display Not Working
|
||||
|
||||
1. **Check VCOM voltage** — it must match the label on your display
|
||||
2. **Check SPI is enabled** — `ls /dev/spi*` should list two devices
|
||||
3. **Enable SPI if missing** — `sudo raspi-config` → Interface Options → SPI → Enable
|
||||
4. **Check permissions** — `sudo usermod -a -G spi $USER`, then log out and back in
|
||||
5. **Isolate the problem** — run with `--virtual` to test the software without hardware
|
||||
|
||||
### Touch Not Working
|
||||
|
||||
1. **Check I2C connections** — `i2cdetect -y 1` should show `0x38`
|
||||
2. **Enable I2C if missing** — `sudo raspi-config` → Interface Options → I2C → Enable
|
||||
3. **Confirm the module loaded** — `lsmod | grep i2c` should show `i2c_dev` and `i2c_bcm2835`
|
||||
4. **Check permissions** — `sudo usermod -a -G i2c $USER`, then log out and back in
|
||||
5. **Calibrate touch** — see the dreader-hal calibration docs
|
||||
|
||||
### Buttons Not Working
|
||||
|
||||
1. **Check the wiring polarity** — prev/next go to GND; the power button goes to 3.3V
|
||||
2. **Verify the pin numbers** are BCM, not physical pin numbers
|
||||
3. **Check permissions** — `sudo usermod -a -G gpio $USER`, then log out and back in
|
||||
4. **Test the pin directly** with `gpioget` (see [section 8](#check-buttons))
|
||||
5. **Check for conflicts** — make sure no other program holds those GPIOs
|
||||
|
||||
### Buttons Trigger Multiple Times
|
||||
|
||||
1. Increase `bounce_time_ms` in the config (try 300–500ms)
|
||||
2. Add a hardware debounce capacitor (0.1µF between GPIO and GND)
|
||||
3. Check for loose connections
|
||||
|
||||
### Button Performs the Wrong Action
|
||||
|
||||
1. Check the `gesture` field for that button in `hardware_config.json`
|
||||
2. Run with `--verbose` to see which gesture is actually emitted
|
||||
|
||||
### Tilt Gestures Not Working
|
||||
|
||||
1. **Calibrate first** — `python examples/calibrate_accelerometer.py`
|
||||
2. **Enable the accelerometer** — `HardwareDisplayHAL(enable_orientation=True)`
|
||||
3. **Verify the calibration loaded** — `hal.load_accelerometer_calibration()` returns `True`
|
||||
4. **Confirm you are polling** — call `get_event()` or `get_tilt_gesture()` in your loop
|
||||
5. **Adjust sensitivity** — tune `tilt_threshold` and `debounce_time`
|
||||
|
||||
If tilting forward turns the page *backward*, the direction heuristic is inverted for
|
||||
your device's mounting. Flip the comparison in `get_tilt_gesture()` in
|
||||
[dreader/hal_hardware.py](../dreader/hal_hardware.py):
|
||||
|
||||
```python
|
||||
if perp_y < 0: # was: perp_y > 0
|
||||
gesture = AppGestureType.TILT_FORWARD
|
||||
else:
|
||||
gesture = AppGestureType.TILT_BACKWARD
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
|
||||
If you see `ModuleNotFoundError` for the drivers, reinstall them all:
|
||||
|
||||
```bash
|
||||
cd external/dreader-hal/external
|
||||
for dir in */; do pip install -e "$dir"; done
|
||||
cd ../../..
|
||||
```
|
||||
|
||||
### Display Ghosting
|
||||
|
||||
E-ink panels retain a faint previous image. The HAL performs a full refresh every 10
|
||||
page turns automatically to clear it. See the dreader-hal documentation for manual
|
||||
refresh control.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance Notes
|
||||
|
||||
### E-ink Refresh Modes
|
||||
|
||||
dreader-hal selects the refresh mode automatically:
|
||||
|
||||
| Mode | Time | Used for |
|
||||
|------|------|----------|
|
||||
| **Fast** (DU) | ~200ms | Text updates |
|
||||
| **Quality** (GC16) | ~1000ms | Images |
|
||||
| **Full** (INIT) | ~1000ms | Every 10 pages, to clear ghosting |
|
||||
|
||||
### Battery Life
|
||||
|
||||
With default settings:
|
||||
|
||||
- Active reading: ~10–20 hours
|
||||
- Standby (display sleeping): ~1–2 weeks
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- Base application: ~30–50MB
|
||||
- Per book: ~10–30MB depending on size
|
||||
- Minimum 512MB RAM recommended
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Programmatic Usage
|
||||
|
||||
Driving the hardware HAL directly from your own script:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
async def main():
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
vcom=-2.06, # YOUR DISPLAY'S VCOM!
|
||||
virtual_display=False,
|
||||
enable_orientation=True,
|
||||
enable_rtc=True,
|
||||
enable_power_monitor=True,
|
||||
)
|
||||
|
||||
config = AppConfig(
|
||||
display_hal=hal,
|
||||
library_path="/home/pi/Books",
|
||||
page_size=(1872, 1404),
|
||||
)
|
||||
|
||||
app = DReaderApplication(config)
|
||||
|
||||
try:
|
||||
await hal.initialize()
|
||||
await app.start()
|
||||
|
||||
while app.is_running():
|
||||
event = await hal.get_touch_event()
|
||||
if event:
|
||||
await app.handle_touch(event)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
finally:
|
||||
await app.shutdown()
|
||||
await hal.cleanup()
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [README.md](../README.md) — application features and usage
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — system architecture and design
|
||||
- [HAL_IMPLEMENTATION_SPEC.md](HAL_IMPLEMENTATION_SPEC.md) — writing a HAL for other hardware
|
||||
- [examples/](../examples/) — runnable examples
|
||||
- [external/dreader-hal/README.md](../external/dreader-hal/README.md) — HAL library details
|
||||
|
||||
### Support
|
||||
|
||||
- Hardware issues: [dreader-hal issues](https://gitea.tourolle.paris/dtourolle/dreader-hal/issues)
|
||||
- Application issues: [dreader-application issues](https://gitea.tourolle.paris/dtourolle/dreader-application/issues)
|
||||
@@ -0,0 +1,196 @@
|
||||
# E-Reader Application Requirements
|
||||
|
||||
## Overview
|
||||
This document defines the core requirements for a full-featured e-reader application built on dreader/pyWebLayout. The application supports library browsing, reading with overlays, state persistence, and gesture-based interaction.
|
||||
|
||||
## Application Modes
|
||||
|
||||
### LIBRARY Mode
|
||||
Browse and select books from the user's library.
|
||||
|
||||
**Features**:
|
||||
- Grid display of books with covers and metadata (title, author)
|
||||
- Book selection via tap/click
|
||||
- Visual feedback on selection
|
||||
|
||||
**Interactions**:
|
||||
- **Tap book**: Open in READING mode
|
||||
- **Swipe**: Scroll library (future)
|
||||
|
||||
### READING Mode
|
||||
Read the current book with page navigation.
|
||||
|
||||
**Features**:
|
||||
- Page rendering via pyWebLayout
|
||||
- Page navigation (next/previous)
|
||||
- Access to overlays
|
||||
- Progress indicator
|
||||
|
||||
**Interactions**:
|
||||
- **Tap edges**: Previous/Next page
|
||||
- **Swipe left/right**: Page navigation
|
||||
- **Swipe up (from bottom)**: Open navigation overlay
|
||||
- **Swipe down (from top)**: Open settings overlay
|
||||
- **Pinch in/out**: Adjust font size
|
||||
- **Long-press on word**: Highlight/lookup (future)
|
||||
|
||||
## Overlay System
|
||||
|
||||
### Navigation Overlay
|
||||
Unified overlay with tabbed interface for navigation.
|
||||
|
||||
**Tabs**:
|
||||
- **Contents**: Chapter list for TOC navigation
|
||||
- **Bookmarks**: Saved positions with jump/delete/add
|
||||
|
||||
**Interactions**:
|
||||
- **Tap chapter/bookmark**: Jump to location, close overlay
|
||||
- **Tap tab**: Switch between Contents and Bookmarks
|
||||
- **Swipe down**: Close overlay
|
||||
|
||||
### Settings Overlay
|
||||
Adjust reading preferences with real-time preview.
|
||||
|
||||
**Controls**:
|
||||
- Font size (A-, A+)
|
||||
- Line spacing (+/-)
|
||||
- Block spacing (+/-)
|
||||
- Word spacing (+/-)
|
||||
- Back to Library button
|
||||
|
||||
**Interactions**:
|
||||
- **Tap buttons**: Adjust settings immediately
|
||||
- **Swipe down**: Close overlay
|
||||
|
||||
### Word Lookup Overlay (Planned - Phase 2)
|
||||
Provide word definitions and contextual information.
|
||||
|
||||
**Features**:
|
||||
- **Dictionary**: Word definition, pronunciation
|
||||
- **X-Ray**: LLM-generated spoiler-free character/place/concept information up to current reading position
|
||||
- **Highlight**: Add colored highlight
|
||||
- **Copy**: Copy to clipboard
|
||||
|
||||
**X-Ray Behavior**:
|
||||
- Pre-generated per book via offline LLM analysis
|
||||
- Only shows information revealed up to current page (spoiler-free)
|
||||
- Character relationships, place descriptions, concept explanations
|
||||
- Entity occurrence tracking
|
||||
|
||||
## State Management
|
||||
|
||||
### Persistent State Structure
|
||||
```json
|
||||
{
|
||||
"mode": "reading|library",
|
||||
"overlay": "none|navigation|settings|word_lookup",
|
||||
"current_book": {
|
||||
"path": "/path/to/book.epub",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name"
|
||||
},
|
||||
"library": {
|
||||
"books_path": "/path/to/library",
|
||||
"scan_cache": [...]
|
||||
},
|
||||
"settings": {
|
||||
"font_scale": 1.0,
|
||||
"line_spacing": 5,
|
||||
"inter_block_spacing": 15,
|
||||
"brightness": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State Location**: `~/.config/dreader/state.json`
|
||||
|
||||
**Save Triggers**:
|
||||
- Every 60 seconds (auto-save)
|
||||
- On mode change
|
||||
- On settings change
|
||||
- On application shutdown
|
||||
|
||||
**Boot Behavior**:
|
||||
- **Cold start**: Show library
|
||||
- **Resume**: Reopen last book at saved position with restored settings
|
||||
- **Error handling**: Fall back to library if book missing or state corrupt
|
||||
|
||||
### Position Persistence
|
||||
- Per-book positions stored via EbookReader bookmark system
|
||||
- Special bookmark `__auto_resume__` for last reading position
|
||||
- Position stable across font size and spacing changes
|
||||
|
||||
## Library Management
|
||||
|
||||
**Features**:
|
||||
- Scan directory for EPUB files
|
||||
- Extract metadata (title, author) and cover images
|
||||
- Cache covers to disk for performance
|
||||
- Incremental updates (scan only new/modified files)
|
||||
|
||||
**Cache Structure**:
|
||||
```
|
||||
~/.config/dreader/
|
||||
├── state.json # Application state
|
||||
├── covers/ # Cached cover images
|
||||
├── bookmarks/ # Per-book bookmarks
|
||||
├── highlights/ # Per-book highlights
|
||||
└── xray/ # X-Ray data (future)
|
||||
```
|
||||
|
||||
## Gesture Handling
|
||||
|
||||
### Reading Mode Gestures
|
||||
- `TAP`: Word selection, link following, page turn (edges)
|
||||
- `SWIPE_LEFT/RIGHT`: Page navigation
|
||||
- `SWIPE_UP` (from bottom 20%): Open navigation overlay
|
||||
- `SWIPE_DOWN` (from top 20%): Open settings overlay
|
||||
- `PINCH_IN/OUT`: Font size adjustment
|
||||
- `LONG_PRESS`: Word lookup (future)
|
||||
|
||||
### Overlay Mode Gestures
|
||||
- `TAP`: Interact with overlay elements
|
||||
- `SWIPE_DOWN`: Close overlay
|
||||
|
||||
### Library Mode Gestures
|
||||
- `TAP`: Select book
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Performance Targets
|
||||
- Boot time: < 3 seconds
|
||||
- Page turn: < 200ms
|
||||
- Library load: < 1 second (up to 100 books)
|
||||
- State save: < 50ms (non-blocking)
|
||||
|
||||
### Platform Integration
|
||||
Application requires a display HAL (Hardware Abstraction Layer):
|
||||
```python
|
||||
class DisplayHAL(ABC):
|
||||
def show_image(image: Image.Image)
|
||||
def get_touch_events() -> Iterator[TouchEvent]
|
||||
def set_brightness(level: int)
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 (MVP) - Complete ✅
|
||||
- Core reading (page navigation, bookmarks)
|
||||
- Library browsing and book selection
|
||||
- Navigation overlay (TOC + Bookmarks)
|
||||
- Settings overlay with persistence
|
||||
- State management and auto-resume
|
||||
- Gesture handling
|
||||
- Highlighting system
|
||||
|
||||
### Phase 2 - In Progress
|
||||
- Word lookup overlay with dictionary
|
||||
- X-Ray feature (spoiler-free contextual info)
|
||||
- Enhanced library features (search, sort)
|
||||
|
||||
### Phase 3 - Future
|
||||
- Night/sepia themes
|
||||
- Full-text search within books
|
||||
- Cloud sync for bookmarks
|
||||
- PDF support
|
||||
- Reading statistics
|
||||
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 648 KiB After Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 543 KiB After Width: | Height: | Size: 28 KiB |
@@ -24,7 +24,8 @@ from dreader.state import (
|
||||
OverlayState
|
||||
)
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.overlay import OverlayManager
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
@@ -54,6 +55,12 @@ __all__ = [
|
||||
# Library
|
||||
"LibraryManager",
|
||||
|
||||
# Overlay
|
||||
"OverlayManager",
|
||||
# Main application
|
||||
"DReaderApplication",
|
||||
"AppConfig",
|
||||
|
||||
# HAL interfaces
|
||||
"DisplayHAL",
|
||||
"KeyboardInputHAL",
|
||||
"EventLoopHAL",
|
||||
]
|
||||
|
||||
@@ -41,19 +41,19 @@ import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.abstract.block import Block, HeadingLevel
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlight_from_query_result
|
||||
|
||||
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||
from .state import OverlayState
|
||||
from .overlay import OverlayManager
|
||||
from .managers import DocumentManager, SettingsManager, HighlightCoordinator
|
||||
from .handlers import GestureRouter
|
||||
from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay
|
||||
|
||||
|
||||
class EbookReader:
|
||||
@@ -103,88 +103,85 @@ class EbookReader:
|
||||
self.page_style = PageStyle(
|
||||
background_color=background_color,
|
||||
border_width=margin,
|
||||
border_color=(200, 200, 200),
|
||||
border_color=background_color,
|
||||
padding=(10, 10, 10, 10),
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing
|
||||
)
|
||||
|
||||
# State
|
||||
# Core managers (NEW: Refactored into separate modules)
|
||||
self.doc_manager = DocumentManager()
|
||||
self.settings_manager = SettingsManager()
|
||||
self.highlight_coordinator: Optional[HighlightCoordinator] = None
|
||||
self.gesture_router = GestureRouter(self)
|
||||
|
||||
# Layout manager (initialized after loading)
|
||||
self.manager: Optional[EreaderLayoutManager] = None
|
||||
|
||||
# Legacy compatibility properties
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
self.document_id: Optional[str] = None
|
||||
self.book_title: Optional[str] = None
|
||||
self.book_author: Optional[str] = None
|
||||
self.highlight_manager: Optional[HighlightManager] = None
|
||||
self.highlight_manager = None # Will delegate to highlight_coordinator
|
||||
|
||||
# Font scale state
|
||||
# Font scale state (delegated to settings_manager but kept for compatibility)
|
||||
self.base_font_scale = 1.0
|
||||
self.font_scale_step = 0.1 # 10% change per step
|
||||
self.font_scale_step = 0.1
|
||||
|
||||
# Selection state (for text selection gestures)
|
||||
self._selection_start: Optional[Tuple[int, int]] = None
|
||||
self._selection_end: Optional[Tuple[int, int]] = None
|
||||
self._selected_range: Optional[SelectionRange] = None
|
||||
|
||||
# Overlay management
|
||||
self.overlay_manager = OverlayManager(page_size=page_size)
|
||||
# Overlay sub-applications
|
||||
self._overlay_subapps = {
|
||||
OverlayState.NAVIGATION: NavigationOverlay(self),
|
||||
OverlayState.SETTINGS: SettingsOverlay(self),
|
||||
OverlayState.TOC: TOCOverlay(self),
|
||||
}
|
||||
self._active_overlay = None # Current active overlay sub-application
|
||||
self.current_overlay_state = OverlayState.NONE
|
||||
|
||||
def load_epub(self, epub_path: str) -> bool:
|
||||
"""
|
||||
Load an EPUB file into the reader.
|
||||
|
||||
|
||||
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.book_title = book.get_title() or "Unknown Title"
|
||||
self.book_author = book.get_metadata('AUTHOR') or "Unknown Author"
|
||||
|
||||
# Create document ID from filename
|
||||
self.document_id = Path(epub_path).stem
|
||||
|
||||
# Extract all blocks from chapters
|
||||
self.blocks = []
|
||||
for chapter in book.chapters:
|
||||
if hasattr(chapter, '_blocks'):
|
||||
self.blocks.extend(chapter._blocks)
|
||||
|
||||
if not self.blocks:
|
||||
raise ValueError("No content blocks found in EPUB")
|
||||
|
||||
# Initialize the ereader manager
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
# Use DocumentManager to load the EPUB
|
||||
success = self.doc_manager.load_epub(epub_path)
|
||||
|
||||
# Initialize highlight manager for this document
|
||||
self.highlight_manager = HighlightManager(
|
||||
document_id=self.document_id,
|
||||
highlights_dir=self.highlights_dir
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading EPUB: {e}")
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Set compatibility properties
|
||||
self.book_title = self.doc_manager.title
|
||||
self.book_author = self.doc_manager.author
|
||||
self.document_id = self.doc_manager.document_id
|
||||
self.blocks = self.doc_manager.blocks
|
||||
|
||||
# Initialize the ereader manager
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Initialize managers that depend on layout manager
|
||||
self.settings_manager.set_manager(self.manager)
|
||||
|
||||
# Initialize highlight coordinator for this document
|
||||
self.highlight_coordinator = HighlightCoordinator(
|
||||
document_id=self.document_id,
|
||||
highlights_dir=self.highlights_dir
|
||||
)
|
||||
self.highlight_coordinator.set_layout_manager(self.manager)
|
||||
self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility
|
||||
|
||||
return True
|
||||
|
||||
def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool:
|
||||
"""
|
||||
@@ -202,41 +199,41 @@ class EbookReader:
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse HTML into blocks
|
||||
blocks = parse_html_string(html_string)
|
||||
# Use DocumentManager to load HTML
|
||||
success = self.doc_manager.load_html(html_string, title, author, document_id)
|
||||
|
||||
if not blocks:
|
||||
raise ValueError("No content blocks parsed from HTML")
|
||||
|
||||
# Set metadata
|
||||
self.book_title = title
|
||||
self.book_author = author
|
||||
self.document_id = document_id
|
||||
self.blocks = blocks
|
||||
|
||||
# Initialize the ereader manager
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Initialize highlight manager for this document
|
||||
self.highlight_manager = HighlightManager(
|
||||
document_id=self.document_id,
|
||||
highlights_dir=self.highlights_dir
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading HTML: {e}")
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Set compatibility properties
|
||||
self.book_title = self.doc_manager.title
|
||||
self.book_author = self.doc_manager.author
|
||||
self.document_id = self.doc_manager.document_id
|
||||
self.blocks = self.doc_manager.blocks
|
||||
|
||||
# Initialize the ereader manager
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Initialize managers that depend on layout manager
|
||||
self.settings_manager.set_manager(self.manager)
|
||||
|
||||
# Initialize highlight coordinator for this document
|
||||
self.highlight_coordinator = HighlightCoordinator(
|
||||
document_id=self.document_id,
|
||||
highlights_dir=self.highlights_dir
|
||||
)
|
||||
self.highlight_coordinator.set_layout_manager(self.manager)
|
||||
self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility
|
||||
|
||||
return True
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if a book is currently loaded."""
|
||||
return self.manager is not None
|
||||
@@ -245,15 +242,27 @@ class EbookReader:
|
||||
"""
|
||||
Get the current page as a PIL Image.
|
||||
|
||||
If an overlay is currently open, returns the composited overlay image.
|
||||
Otherwise returns the base reading page.
|
||||
|
||||
Args:
|
||||
include_highlights: Whether to overlay highlights on the page
|
||||
include_highlights: Whether to overlay highlights on the page (only applies to base page)
|
||||
|
||||
Returns:
|
||||
PIL Image of the current page, or None if no book is loaded
|
||||
PIL Image of the current page (or overlay), or None if no book is loaded
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
# If an overlay is open, return the cached composited overlay image
|
||||
if self.is_overlay_open() and self._active_overlay:
|
||||
# Return the composited overlay from the sub-application
|
||||
if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image:
|
||||
return self._active_overlay.composite_overlay(
|
||||
self._active_overlay._cached_base_page,
|
||||
self._active_overlay._cached_overlay_image
|
||||
)
|
||||
|
||||
try:
|
||||
page = self.manager.get_current_page()
|
||||
img = page.render()
|
||||
@@ -454,141 +463,108 @@ class EbookReader:
|
||||
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:
|
||||
PIL Image of the re-rendered page with new font size
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
self.base_font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x
|
||||
page = self.manager.set_font_scale(self.base_font_scale)
|
||||
return page.render()
|
||||
except Exception as e:
|
||||
print(f"Error setting font size: {e}")
|
||||
return None
|
||||
|
||||
result = self.settings_manager.set_font_size(scale)
|
||||
if result:
|
||||
self.base_font_scale = self.settings_manager.font_scale # Sync compatibility property
|
||||
return result
|
||||
|
||||
def increase_font_size(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Increase font size by one step and re-render.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
new_scale = self.base_font_scale + self.font_scale_step
|
||||
return self.set_font_size(new_scale)
|
||||
|
||||
result = self.settings_manager.increase_font_size()
|
||||
if result:
|
||||
self.base_font_scale = self.settings_manager.font_scale
|
||||
return result
|
||||
|
||||
def decrease_font_size(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Decrease font size by one step and re-render.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
new_scale = self.base_font_scale - self.font_scale_step
|
||||
return self.set_font_size(new_scale)
|
||||
|
||||
result = self.settings_manager.decrease_font_size()
|
||||
if result:
|
||||
self.base_font_scale = self.settings_manager.font_scale
|
||||
return result
|
||||
|
||||
def get_font_size(self) -> float:
|
||||
"""
|
||||
Get the current font size scale.
|
||||
|
||||
|
||||
Returns:
|
||||
Current font scale factor
|
||||
"""
|
||||
return self.base_font_scale
|
||||
|
||||
return self.settings_manager.get_font_size()
|
||||
|
||||
def set_font_family(self, font_family) -> 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:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
return self.settings_manager.set_font_family(font_family)
|
||||
|
||||
def get_font_family(self):
|
||||
"""
|
||||
Get the current font family.
|
||||
|
||||
Returns:
|
||||
Current BundledFont or None if using document default
|
||||
"""
|
||||
return self.settings_manager.get_font_family()
|
||||
|
||||
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set line spacing and re-render current page.
|
||||
|
||||
Set line spacing using pyWebLayout's native support.
|
||||
|
||||
Args:
|
||||
spacing: Line spacing in pixels
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Update page style
|
||||
self.page_style.line_spacing = max(0, spacing)
|
||||
|
||||
# Need to recreate the manager with new page style
|
||||
current_pos = self.manager.current_position
|
||||
current_font_scale = self.base_font_scale
|
||||
self.manager.shutdown()
|
||||
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Restore position
|
||||
self.manager.current_position = current_pos
|
||||
|
||||
# Restore font scale using the method (not direct assignment)
|
||||
if current_font_scale != 1.0:
|
||||
self.manager.set_font_scale(current_font_scale)
|
||||
|
||||
page = self.manager.get_current_page()
|
||||
return page.render()
|
||||
except Exception as e:
|
||||
print(f"Error setting line spacing: {e}")
|
||||
return None
|
||||
|
||||
return self.settings_manager.set_line_spacing(spacing)
|
||||
|
||||
def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set spacing between blocks (paragraphs, headings, etc.) and re-render.
|
||||
|
||||
Set inter-block spacing using pyWebLayout's native support.
|
||||
|
||||
Args:
|
||||
spacing: Inter-block spacing in pixels
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Update page style
|
||||
self.page_style.inter_block_spacing = max(0, spacing)
|
||||
|
||||
# Need to recreate the manager with new page style
|
||||
current_pos = self.manager.current_position
|
||||
current_font_scale = self.base_font_scale
|
||||
self.manager.shutdown()
|
||||
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Restore position
|
||||
self.manager.current_position = current_pos
|
||||
|
||||
# Restore font scale using the method (not direct assignment)
|
||||
if current_font_scale != 1.0:
|
||||
self.manager.set_font_scale(current_font_scale)
|
||||
|
||||
page = self.manager.get_current_page()
|
||||
return page.render()
|
||||
except Exception as e:
|
||||
print(f"Error setting inter-block spacing: {e}")
|
||||
return None
|
||||
|
||||
return self.settings_manager.set_inter_block_spacing(spacing)
|
||||
|
||||
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:
|
||||
PIL Image of the re-rendered page
|
||||
"""
|
||||
return self.settings_manager.set_word_spacing(spacing)
|
||||
|
||||
def get_position_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about the current position.
|
||||
@@ -686,6 +662,35 @@ class EbookReader:
|
||||
'font_scale': self.base_font_scale
|
||||
}
|
||||
|
||||
# ===== Settings Persistence =====
|
||||
|
||||
def get_current_settings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current rendering settings.
|
||||
|
||||
Returns:
|
||||
Dictionary with all current settings
|
||||
"""
|
||||
return self.settings_manager.get_current_settings()
|
||||
|
||||
def apply_settings(self, settings: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Apply rendering settings from a settings dictionary.
|
||||
|
||||
This should be called after loading a book to restore user preferences.
|
||||
|
||||
Args:
|
||||
settings: Dictionary with settings (font_scale, line_spacing, etc.)
|
||||
|
||||
Returns:
|
||||
True if settings applied successfully, False otherwise
|
||||
"""
|
||||
success = self.settings_manager.apply_settings(settings)
|
||||
if success:
|
||||
# Sync compatibility property
|
||||
self.base_font_scale = self.settings_manager.font_scale
|
||||
return success
|
||||
|
||||
# ===== Gesture Handling =====
|
||||
# All business logic for touch input is handled here
|
||||
|
||||
@@ -702,41 +707,8 @@ class EbookReader:
|
||||
Returns:
|
||||
GestureResponse with action and data for UI to process
|
||||
"""
|
||||
if not self.is_loaded():
|
||||
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
|
||||
|
||||
# Handle overlay-specific gestures first
|
||||
if self.is_overlay_open():
|
||||
if event.gesture == GestureType.TAP:
|
||||
return self._handle_overlay_tap(event.x, event.y)
|
||||
elif event.gesture == GestureType.SWIPE_DOWN:
|
||||
# Swipe down closes overlay
|
||||
return self._handle_overlay_close()
|
||||
|
||||
# Dispatch based on gesture type for normal reading mode
|
||||
if event.gesture == GestureType.TAP:
|
||||
return self._handle_tap(event.x, event.y)
|
||||
elif event.gesture == GestureType.LONG_PRESS:
|
||||
return self._handle_long_press(event.x, event.y)
|
||||
elif event.gesture == GestureType.SWIPE_LEFT:
|
||||
return self._handle_page_forward()
|
||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||
return self._handle_page_back()
|
||||
elif event.gesture == GestureType.SWIPE_UP:
|
||||
# Swipe up from bottom opens TOC overlay
|
||||
return self._handle_swipe_up(event.y)
|
||||
elif event.gesture == GestureType.PINCH_IN:
|
||||
return self._handle_zoom_out()
|
||||
elif event.gesture == GestureType.PINCH_OUT:
|
||||
return self._handle_zoom_in()
|
||||
elif event.gesture == GestureType.DRAG_START:
|
||||
return self._handle_selection_start(event.x, event.y)
|
||||
elif event.gesture == GestureType.DRAG_MOVE:
|
||||
return self._handle_selection_move(event.x, event.y)
|
||||
elif event.gesture == GestureType.DRAG_END:
|
||||
return self._handle_selection_end(event.x, event.y)
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
# Delegate to gesture router
|
||||
return self.gesture_router.handle_touch(event)
|
||||
|
||||
def query_pixel(self, x: int, y: int) -> Optional[QueryResult]:
|
||||
"""
|
||||
@@ -754,221 +726,29 @@ class EbookReader:
|
||||
page = self.manager.get_current_page()
|
||||
return page.query_point((x, y))
|
||||
|
||||
def _handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle tap gesture - activates links or selects words"""
|
||||
page = self.manager.get_current_page()
|
||||
result = page.query_point((x, y))
|
||||
|
||||
if not result or result.object_type == "empty":
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
# If it's a link, navigate
|
||||
if result.is_interactive and result.link_target:
|
||||
# Handle different link types
|
||||
if result.link_target.endswith('.epub'):
|
||||
# Open new book
|
||||
success = self.load_epub(result.link_target)
|
||||
if success:
|
||||
return GestureResponse(ActionType.BOOK_LOADED, {
|
||||
"title": self.book_title,
|
||||
"author": self.book_author,
|
||||
"path": result.link_target
|
||||
})
|
||||
else:
|
||||
return GestureResponse(ActionType.ERROR, {
|
||||
"message": f"Failed to load {result.link_target}"
|
||||
})
|
||||
else:
|
||||
# Internal navigation (chapter)
|
||||
self.jump_to_chapter(result.link_target)
|
||||
return GestureResponse(ActionType.NAVIGATE, {
|
||||
"target": result.link_target,
|
||||
"chapter": self.get_current_chapter_info()
|
||||
})
|
||||
|
||||
# Just a tap on text - select word
|
||||
if result.text:
|
||||
return GestureResponse(ActionType.WORD_SELECTED, {
|
||||
"word": result.text,
|
||||
"bounds": result.bounds
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_long_press(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle long-press - show definition or menu"""
|
||||
page = self.manager.get_current_page()
|
||||
result = page.query_point((x, y))
|
||||
|
||||
if result and result.text:
|
||||
return GestureResponse(ActionType.DEFINE, {
|
||||
"word": result.text,
|
||||
"bounds": result.bounds
|
||||
})
|
||||
|
||||
# Long-press on empty - show menu
|
||||
return GestureResponse(ActionType.SHOW_MENU, {
|
||||
"options": ["bookmark", "settings", "toc", "search"]
|
||||
})
|
||||
|
||||
def _handle_page_forward(self) -> GestureResponse:
|
||||
"""Handle swipe left - next page"""
|
||||
img = self.next_page()
|
||||
if img:
|
||||
return GestureResponse(ActionType.PAGE_TURN, {
|
||||
"direction": "forward",
|
||||
"progress": self.get_reading_progress(),
|
||||
"chapter": self.get_current_chapter_info()
|
||||
})
|
||||
return GestureResponse(ActionType.AT_END, {})
|
||||
|
||||
def _handle_page_back(self) -> GestureResponse:
|
||||
"""Handle swipe right - previous page"""
|
||||
img = self.previous_page()
|
||||
if img:
|
||||
return GestureResponse(ActionType.PAGE_TURN, {
|
||||
"direction": "back",
|
||||
"progress": self.get_reading_progress(),
|
||||
"chapter": self.get_current_chapter_info()
|
||||
})
|
||||
return GestureResponse(ActionType.AT_START, {})
|
||||
|
||||
def _handle_zoom_in(self) -> GestureResponse:
|
||||
"""Handle pinch out - increase font"""
|
||||
self.increase_font_size()
|
||||
return GestureResponse(ActionType.ZOOM, {
|
||||
"direction": "in",
|
||||
"font_scale": self.base_font_scale
|
||||
})
|
||||
|
||||
def _handle_zoom_out(self) -> GestureResponse:
|
||||
"""Handle pinch in - decrease font"""
|
||||
self.decrease_font_size()
|
||||
return GestureResponse(ActionType.ZOOM, {
|
||||
"direction": "out",
|
||||
"font_scale": self.base_font_scale
|
||||
})
|
||||
|
||||
def _handle_selection_start(self, x: int, y: int) -> GestureResponse:
|
||||
"""Start text selection"""
|
||||
self._selection_start = (x, y)
|
||||
self._selection_end = None
|
||||
self._selected_range = None
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_START, {
|
||||
"start": (x, y)
|
||||
})
|
||||
|
||||
def _handle_selection_move(self, x: int, y: int) -> GestureResponse:
|
||||
"""Update text selection"""
|
||||
if not self._selection_start:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
self._selection_end = (x, y)
|
||||
|
||||
# Query range
|
||||
page = self.manager.get_current_page()
|
||||
self._selected_range = page.query_range(
|
||||
self._selection_start,
|
||||
self._selection_end
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_UPDATE, {
|
||||
"start": self._selection_start,
|
||||
"end": self._selection_end,
|
||||
"text_count": len(self._selected_range.results),
|
||||
"bounds": self._selected_range.bounds_list
|
||||
})
|
||||
|
||||
def _handle_selection_end(self, x: int, y: int) -> GestureResponse:
|
||||
"""End text selection and return selected text"""
|
||||
if not self._selection_start:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
self._selection_end = (x, y)
|
||||
|
||||
page = self.manager.get_current_page()
|
||||
self._selected_range = page.query_range(
|
||||
self._selection_start,
|
||||
self._selection_end
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_COMPLETE, {
|
||||
"text": self._selected_range.text,
|
||||
"word_count": len(self._selected_range.results),
|
||||
"bounds": self._selected_range.bounds_list
|
||||
})
|
||||
|
||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
|
||||
# Check if swipe started from bottom 20% of screen
|
||||
bottom_threshold = self.page_size[1] * 0.8
|
||||
|
||||
if y >= bottom_threshold:
|
||||
# Open TOC overlay
|
||||
overlay_image = self.open_toc_overlay()
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "toc",
|
||||
"chapters": self.get_chapters()
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle tap when overlay is open - select chapter or close overlay"""
|
||||
# For TOC overlay, use pyWebLayout link query to detect chapter clicks
|
||||
if self.current_overlay_state == OverlayState.TOC:
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.overlay_manager.query_overlay_pixel(x, y)
|
||||
"""
|
||||
Handle tap when overlay is open.
|
||||
|
||||
# If query failed (tap outside overlay), close it
|
||||
if not query_result:
|
||||
self.close_overlay()
|
||||
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
|
||||
chapters = self.get_chapters()
|
||||
chapter_title = None
|
||||
for title, idx in chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.jump_to_chapter(chapter_idx)
|
||||
|
||||
# Close overlay
|
||||
self.close_overlay()
|
||||
|
||||
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
|
||||
Delegates to the active overlay sub-application for handling.
|
||||
If the response indicates the overlay should be closed, closes it.
|
||||
"""
|
||||
if not self._active_overlay:
|
||||
# No active overlay, close legacy overlay if any
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# For other overlays, just close on any tap for now
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
# Delegate to the active overlay sub-application
|
||||
response = self._active_overlay.handle_tap(x, y)
|
||||
|
||||
# If the response indicates overlay should be closed, close it
|
||||
if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED,
|
||||
ActionType.BOOKMARK_SELECTED):
|
||||
self.close_overlay()
|
||||
|
||||
return response
|
||||
|
||||
def _handle_overlay_close(self) -> GestureResponse:
|
||||
"""Handle overlay close gesture (swipe down)"""
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# ===================================================================
|
||||
# Highlighting API
|
||||
@@ -1200,15 +980,19 @@ class EbookReader:
|
||||
# Get chapters
|
||||
chapters = self.get_chapters()
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_toc_overlay(chapters, base_page)
|
||||
# Use the TOC sub-application
|
||||
overlay_subapp = self._overlay_subapps[OverlayState.TOC]
|
||||
result = overlay_subapp.open(base_page, chapters=chapters)
|
||||
|
||||
# Update state
|
||||
self._active_overlay = overlay_subapp
|
||||
self.current_overlay_state = OverlayState.TOC
|
||||
|
||||
return result
|
||||
|
||||
def open_settings_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Open the settings overlay.
|
||||
Open the settings overlay with current settings values.
|
||||
|
||||
Returns:
|
||||
Composited image with settings overlay on top of current page, or None if no book loaded
|
||||
@@ -1221,8 +1005,27 @@ class EbookReader:
|
||||
if not base_page:
|
||||
return None
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_settings_overlay(base_page)
|
||||
# Get current settings
|
||||
font_scale = self.base_font_scale
|
||||
line_spacing = self.page_style.line_spacing
|
||||
inter_block_spacing = self.page_style.inter_block_spacing
|
||||
word_spacing = self.page_style.word_spacing
|
||||
font_family = self.get_font_family()
|
||||
font_family_name = font_family.name if font_family else "Default"
|
||||
|
||||
# Use the Settings sub-application
|
||||
overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS]
|
||||
result = overlay_subapp.open(
|
||||
base_page,
|
||||
font_scale=font_scale,
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing,
|
||||
word_spacing=word_spacing,
|
||||
font_family=font_family_name
|
||||
)
|
||||
|
||||
# Update state
|
||||
self._active_overlay = overlay_subapp
|
||||
self.current_overlay_state = OverlayState.SETTINGS
|
||||
|
||||
return result
|
||||
@@ -1231,9 +1034,26 @@ class EbookReader:
|
||||
"""
|
||||
Open the bookmarks overlay.
|
||||
|
||||
This is a convenience method that opens the navigation overlay with the bookmarks tab active.
|
||||
|
||||
Returns:
|
||||
Composited image with bookmarks overlay on top of current page, or None if no book loaded
|
||||
"""
|
||||
return self.open_navigation_overlay(active_tab="bookmarks")
|
||||
|
||||
def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]:
|
||||
"""
|
||||
Open the unified navigation overlay with Contents and Bookmarks tabs.
|
||||
|
||||
This is the new unified overlay that replaces separate TOC and Bookmarks overlays.
|
||||
It provides a tabbed interface for switching between table of contents and bookmarks.
|
||||
|
||||
Args:
|
||||
active_tab: Which tab to show initially ("contents" or "bookmarks")
|
||||
|
||||
Returns:
|
||||
Composited image with navigation overlay on top of current page, or None if no book loaded
|
||||
"""
|
||||
if not self.is_loaded():
|
||||
return None
|
||||
|
||||
@@ -1242,19 +1062,51 @@ class EbookReader:
|
||||
if not base_page:
|
||||
return None
|
||||
|
||||
# Get bookmarks
|
||||
# Get chapters for Contents tab
|
||||
chapters = self.get_chapters()
|
||||
|
||||
# Get bookmarks for Bookmarks tab
|
||||
bookmark_names = self.list_saved_positions()
|
||||
bookmarks = [
|
||||
{"name": name, "position": f"Saved position"}
|
||||
for name in bookmark_names
|
||||
]
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
|
||||
self.current_overlay_state = OverlayState.BOOKMARKS
|
||||
# Use the Navigation sub-application
|
||||
overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION]
|
||||
result = overlay_subapp.open(
|
||||
base_page,
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab=active_tab
|
||||
)
|
||||
|
||||
# Update state
|
||||
self._active_overlay = overlay_subapp
|
||||
self.current_overlay_state = OverlayState.NAVIGATION
|
||||
|
||||
return result
|
||||
|
||||
def switch_navigation_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, or None if navigation overlay is not open
|
||||
"""
|
||||
if self.current_overlay_state != OverlayState.NAVIGATION:
|
||||
return None
|
||||
|
||||
# Delegate to the Navigation sub-application
|
||||
if isinstance(self._active_overlay, NavigationOverlay):
|
||||
result = self._active_overlay.switch_tab(new_tab)
|
||||
return result if result else self.get_current_page()
|
||||
|
||||
return None
|
||||
|
||||
def close_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the current overlay and return to reading view.
|
||||
@@ -1265,7 +1117,12 @@ class EbookReader:
|
||||
if self.current_overlay_state == OverlayState.NONE:
|
||||
return None
|
||||
|
||||
result = self.overlay_manager.close_overlay()
|
||||
# Close the active overlay sub-application
|
||||
if self._active_overlay:
|
||||
self._active_overlay.close()
|
||||
self._active_overlay = None
|
||||
|
||||
# Update state
|
||||
self.current_overlay_state = OverlayState.NONE
|
||||
|
||||
# Return fresh current page
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -11,6 +13,8 @@ from PIL import Image
|
||||
import ebooklib
|
||||
from ebooklib import epub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
|
||||
"""
|
||||
@@ -44,10 +48,15 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option
|
||||
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,
|
||||
@@ -58,12 +67,19 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option
|
||||
|
||||
# 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,
|
||||
@@ -128,15 +144,20 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i
|
||||
"""
|
||||
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
|
||||
@@ -146,6 +167,7 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i
|
||||
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
|
||||
@@ -154,26 +176,36 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i
|
||||
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
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ class GestureType(Enum):
|
||||
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:
|
||||
@@ -125,3 +129,7 @@ class ActionType:
|
||||
OVERLAY_OPENED = "overlay_opened"
|
||||
OVERLAY_CLOSED = "overlay_closed"
|
||||
CHAPTER_SELECTED = "chapter_selected"
|
||||
BOOKMARK_SELECTED = "bookmark_selected"
|
||||
TAB_SWITCHED = "tab_switched"
|
||||
SETTING_CHANGED = "setting_changed"
|
||||
BACK_TO_LIBRARY = "back_to_library"
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
GPIO Button Handler for DReader.
|
||||
|
||||
This module provides GPIO button support for physical buttons on the e-reader device.
|
||||
Buttons can be mapped to touch gestures for navigation and control.
|
||||
|
||||
Usage:
|
||||
from dreader.gpio_buttons import GPIOButtonHandler
|
||||
|
||||
buttons = GPIOButtonHandler(config)
|
||||
await buttons.initialize()
|
||||
|
||||
# Check for button events
|
||||
event = await buttons.get_button_event()
|
||||
if event:
|
||||
print(f"Button pressed: {event.gesture}")
|
||||
|
||||
await buttons.cleanup()
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .gesture import TouchEvent, GestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import RPi.GPIO
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
GPIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
GPIO_AVAILABLE = False
|
||||
logger.warning("RPi.GPIO not available. Button support disabled.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonConfig:
|
||||
"""Configuration for a single GPIO button."""
|
||||
name: str
|
||||
gpio: int
|
||||
gesture: GestureType
|
||||
description: str = ""
|
||||
pull_up: bool = True # True = pull-up (button pulls LOW), False = pull-down (button pulls HIGH)
|
||||
|
||||
|
||||
class GPIOButtonHandler:
|
||||
"""
|
||||
Handler for GPIO buttons that generates touch events.
|
||||
|
||||
This class manages physical buttons connected to GPIO pins and converts
|
||||
button presses into TouchEvent objects that can be handled by the application.
|
||||
|
||||
Args:
|
||||
buttons: List of ButtonConfig objects defining button mappings
|
||||
pull_up: Use pull-up resistors (default True)
|
||||
bounce_time_ms: Debounce time in milliseconds (default 200)
|
||||
screen_width: Screen width for generating touch coordinates (default 1872)
|
||||
screen_height: Screen height for generating touch coordinates (default 1404)
|
||||
|
||||
Example:
|
||||
buttons_config = [
|
||||
ButtonConfig("next", 23, GestureType.SWIPE_LEFT, "Next page"),
|
||||
ButtonConfig("prev", 24, GestureType.SWIPE_RIGHT, "Previous page"),
|
||||
]
|
||||
|
||||
handler = GPIOButtonHandler(buttons_config)
|
||||
await handler.initialize()
|
||||
|
||||
# In main loop
|
||||
event = await handler.get_button_event()
|
||||
if event:
|
||||
await app.handle_touch(event)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buttons: List[ButtonConfig],
|
||||
pull_up: bool = True,
|
||||
bounce_time_ms: int = 200,
|
||||
screen_width: int = 1872,
|
||||
screen_height: int = 1404,
|
||||
):
|
||||
"""Initialize GPIO button handler."""
|
||||
self.buttons = buttons
|
||||
self.pull_up = pull_up
|
||||
self.bounce_time_ms = bounce_time_ms
|
||||
self.screen_width = screen_width
|
||||
self.screen_height = screen_height
|
||||
|
||||
self._initialized = False
|
||||
self._event_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._gpio_map: Dict[int, ButtonConfig] = {}
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.error("RPi.GPIO not available. Buttons will not work.")
|
||||
return
|
||||
|
||||
logger.info(f"GPIO button handler created with {len(buttons)} buttons")
|
||||
for btn in buttons:
|
||||
active_type = "active low" if btn.pull_up else "active high"
|
||||
logger.info(f" Button '{btn.name}' on GPIO {btn.gpio} -> {btn.gesture.value} ({active_type})")
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize GPIO pins and set up button callbacks."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.warning("Cannot initialize buttons: RPi.GPIO not available")
|
||||
return
|
||||
|
||||
logger.info("Initializing GPIO buttons...")
|
||||
|
||||
# Set GPIO mode
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setwarnings(False)
|
||||
|
||||
# Configure each button
|
||||
for button in self.buttons:
|
||||
try:
|
||||
# Clean up any existing event detection on this pin
|
||||
try:
|
||||
GPIO.remove_event_detect(button.gpio)
|
||||
except Exception:
|
||||
pass # Ignore if no event detection was set
|
||||
|
||||
# Configure pin based on button's pull_up setting
|
||||
if button.pull_up:
|
||||
# Pull-up resistor: button pulls pin LOW when pressed
|
||||
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
||||
edge = GPIO.FALLING
|
||||
logger.debug(f"Button '{button.name}' configured with pull-up (active low)")
|
||||
else:
|
||||
# Pull-down resistor: button pulls pin HIGH when pressed
|
||||
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
|
||||
edge = GPIO.RISING
|
||||
logger.debug(f"Button '{button.name}' configured with pull-down (active high)")
|
||||
|
||||
# Add event detection with debounce
|
||||
GPIO.add_event_detect(
|
||||
button.gpio,
|
||||
edge,
|
||||
callback=lambda channel, btn=button: self._button_callback(btn),
|
||||
bouncetime=self.bounce_time_ms
|
||||
)
|
||||
|
||||
self._gpio_map[button.gpio] = button
|
||||
logger.info(f"✓ Configured button '{button.name}' on GPIO {button.gpio}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure button '{button.name}' on GPIO {button.gpio}: {e}")
|
||||
|
||||
self._initialized = True
|
||||
logger.info("GPIO buttons initialized successfully")
|
||||
|
||||
def _button_callback(self, button: ButtonConfig):
|
||||
"""
|
||||
Callback function for button press (runs in GPIO event thread).
|
||||
|
||||
This is called by RPi.GPIO when a button is pressed. We put the event
|
||||
in a queue for async processing.
|
||||
"""
|
||||
logger.debug(f"Button pressed: {button.name} (GPIO {button.gpio})")
|
||||
|
||||
# Create touch event
|
||||
# Use center of screen for button events (x, y don't matter for swipes)
|
||||
event = TouchEvent(
|
||||
gesture=button.gesture,
|
||||
x=self.screen_width // 2,
|
||||
y=self.screen_height // 2,
|
||||
)
|
||||
|
||||
# Put in queue (non-blocking)
|
||||
try:
|
||||
self._event_queue.put_nowait(event)
|
||||
logger.info(f"Button event queued: {button.name} -> {button.gesture.value}")
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Button event queue full, dropping event")
|
||||
|
||||
async def get_button_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Get the next button event from the queue.
|
||||
|
||||
Returns:
|
||||
TouchEvent if a button was pressed, None if no events
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Non-blocking get
|
||||
event = self._event_queue.get_nowait()
|
||||
return event
|
||||
except asyncio.QueueEmpty:
|
||||
return None
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up GPIO resources."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up GPIO buttons...")
|
||||
|
||||
try:
|
||||
# Remove event detection for all buttons
|
||||
for button in self.buttons:
|
||||
try:
|
||||
GPIO.remove_event_detect(button.gpio)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error removing event detect for GPIO {button.gpio}: {e}")
|
||||
|
||||
# Clean up GPIO
|
||||
GPIO.cleanup()
|
||||
logger.info("GPIO buttons cleaned up")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during GPIO cleanup: {e}")
|
||||
|
||||
self._initialized = False
|
||||
|
||||
|
||||
def load_button_config_from_dict(config: dict, screen_width: int = 1872, screen_height: int = 1404) -> Optional[GPIOButtonHandler]:
|
||||
"""
|
||||
Load GPIO button configuration from a dictionary.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with 'gpio_buttons' section
|
||||
screen_width: Screen width for touch coordinates
|
||||
screen_height: Screen height for touch coordinates
|
||||
|
||||
Returns:
|
||||
GPIOButtonHandler instance if buttons enabled, None otherwise
|
||||
|
||||
Example config:
|
||||
{
|
||||
"gpio_buttons": {
|
||||
"enabled": true,
|
||||
"pull_up": true, # Default pull_up for all buttons
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": [
|
||||
{
|
||||
"name": "next_page",
|
||||
"gpio": 23,
|
||||
"gesture": "swipe_left",
|
||||
"description": "Next page",
|
||||
"pull_up": true # Optional: override per button (true = active low, false = active high)
|
||||
},
|
||||
{
|
||||
"name": "power_off",
|
||||
"gpio": 21,
|
||||
"gesture": "long_press",
|
||||
"description": "Power off",
|
||||
"pull_up": false # Active high button
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
gpio_config = config.get("gpio_buttons", {})
|
||||
|
||||
if not gpio_config.get("enabled", False):
|
||||
logger.info("GPIO buttons disabled in config")
|
||||
return None
|
||||
|
||||
if not GPIO_AVAILABLE:
|
||||
logger.warning("GPIO buttons enabled in config but RPi.GPIO not available")
|
||||
return None
|
||||
|
||||
# Parse button configurations
|
||||
# Get default pull_up from global config for backward compatibility
|
||||
default_pull_up = gpio_config.get("pull_up", True)
|
||||
|
||||
buttons = []
|
||||
for btn_cfg in gpio_config.get("buttons", []):
|
||||
try:
|
||||
# Parse gesture type
|
||||
gesture_str = btn_cfg["gesture"]
|
||||
gesture = GestureType(gesture_str)
|
||||
|
||||
button = ButtonConfig(
|
||||
name=btn_cfg["name"],
|
||||
gpio=btn_cfg["gpio"],
|
||||
gesture=gesture,
|
||||
description=btn_cfg.get("description", ""),
|
||||
pull_up=btn_cfg.get("pull_up", default_pull_up) # Per-button or global default
|
||||
)
|
||||
buttons.append(button)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing button config: {e}")
|
||||
logger.error(f" Config: {btn_cfg}")
|
||||
continue
|
||||
|
||||
if not buttons:
|
||||
logger.warning("No valid button configurations found")
|
||||
return None
|
||||
|
||||
# Create handler
|
||||
handler = GPIOButtonHandler(
|
||||
buttons=buttons,
|
||||
pull_up=gpio_config.get("pull_up", True),
|
||||
bounce_time_ms=gpio_config.get("bounce_time_ms", 200),
|
||||
screen_width=screen_width,
|
||||
screen_height=screen_height,
|
||||
)
|
||||
|
||||
return handler
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Hardware Abstraction Layer (HAL) interface for DReader.
|
||||
|
||||
This module defines the abstract interface that platform-specific
|
||||
display/input implementations must provide.
|
||||
|
||||
The HAL separates the core e-reader logic from platform-specific
|
||||
hardware details (display, touch input, buttons, etc.).
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .gesture import TouchEvent
|
||||
|
||||
|
||||
class DisplayHAL(ABC):
|
||||
"""
|
||||
Abstract interface for display and input hardware.
|
||||
|
||||
Platform-specific implementations should subclass this and provide
|
||||
concrete implementations for all abstract methods.
|
||||
|
||||
The HAL is responsible for:
|
||||
- Displaying images on the screen
|
||||
- Capturing touch/click input and converting to TouchEvent
|
||||
- Hardware-specific features (brightness, sleep, etc.)
|
||||
|
||||
All methods are async to support non-blocking I/O.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display a PIL Image on the screen.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
|
||||
This method should handle:
|
||||
- Converting image format if needed for the display
|
||||
- Scaling/cropping if image size doesn't match display
|
||||
- Updating the physical display hardware
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Wait for and return the next touch event.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None if no event (non-blocking mode)
|
||||
|
||||
This method should:
|
||||
- Read from touch hardware
|
||||
- Convert raw coordinates to TouchEvent
|
||||
- Detect gesture type (tap, swipe, etc.)
|
||||
- Return None immediately if no event available
|
||||
|
||||
Note: For blocking behavior, implement a loop that awaits this
|
||||
method in the main event loop.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness.
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10, where 0=dimmest, 10=brightest)
|
||||
|
||||
Platform implementations should map this to their hardware's
|
||||
actual brightness range.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize the display hardware.
|
||||
|
||||
This optional method is called once before the application starts.
|
||||
Override to perform platform-specific initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
"""
|
||||
Clean up display hardware resources.
|
||||
|
||||
This optional method is called during application shutdown.
|
||||
Override to perform platform-specific cleanup.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def show_message(self, message: str, duration: float = 2.0):
|
||||
"""
|
||||
Display a text message (for loading screens, errors, etc.).
|
||||
|
||||
Args:
|
||||
message: Text message to display
|
||||
duration: How long to show message (seconds)
|
||||
|
||||
Default implementation creates a simple text image.
|
||||
Override for platform-specific message display.
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
# Create simple text image
|
||||
img = Image.new('RGB', (800, 1200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to use a decent font, fall back to default
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw centered text
|
||||
bbox = draw.textbbox((0, 0), message, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (800 - text_width) // 2
|
||||
y = (1200 - text_height) // 2
|
||||
|
||||
draw.text((x, y), message, fill=(0, 0, 0), font=font)
|
||||
|
||||
await self.show_image(img)
|
||||
|
||||
# Wait for duration
|
||||
if duration > 0:
|
||||
import asyncio
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
|
||||
class KeyboardInputHAL(ABC):
|
||||
"""
|
||||
Optional abstract interface for keyboard input.
|
||||
|
||||
This is separate from DisplayHAL to support platforms that have
|
||||
both touch and keyboard input (e.g., desktop testing).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_key_event(self) -> Optional[str]:
|
||||
"""
|
||||
Get the next keyboard event.
|
||||
|
||||
Returns:
|
||||
Key name as string (e.g., "up", "down", "enter", "q")
|
||||
None if no key event available
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EventLoopHAL(DisplayHAL):
|
||||
"""
|
||||
Extended HAL interface that provides its own event loop.
|
||||
|
||||
Some platforms (e.g., Pygame, Qt) have their own event loop that
|
||||
must be used. This interface allows the HAL to run the main loop
|
||||
and call back to the application.
|
||||
|
||||
Usage:
|
||||
hal = MyEventLoopHAL()
|
||||
app = DReaderApplication(AppConfig(display_hal=hal, ...))
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the platform's event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance to send events to
|
||||
|
||||
This method should:
|
||||
1. Initialize the display
|
||||
2. Call app.start()
|
||||
3. Enter event loop
|
||||
4. Call app.handle_touch(event) for each event
|
||||
5. Handle quit events and call app.shutdown()
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,686 @@
|
||||
"""
|
||||
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,
|
||||
rotate: Optional[str] = None,
|
||||
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
|
||||
|
||||
# Load config from file to get display settings
|
||||
full_config = None
|
||||
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)
|
||||
logger.info(f"Loaded hardware config from {config_path}")
|
||||
|
||||
# Override display parameters from config if not explicitly provided
|
||||
display_config = full_config.get('display', {})
|
||||
if rotate is None and 'rotate' in display_config:
|
||||
rotate = display_config['rotate']
|
||||
logger.info(f" Using rotate from config: {rotate}")
|
||||
|
||||
gpio_config = full_config
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load hardware config from {config_path}: {e}")
|
||||
|
||||
logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}")
|
||||
logger.info(f" VCOM: {vcom}V")
|
||||
logger.info(f" Rotate: {rotate}")
|
||||
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,
|
||||
rotate=rotate,
|
||||
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
|
||||
|
||||
# Initialize GPIO buttons if configured
|
||||
if gpio_config and GPIO_BUTTONS_AVAILABLE:
|
||||
try:
|
||||
self.gpio_handler = load_button_config_from_dict(
|
||||
gpio_config,
|
||||
screen_width=width,
|
||||
screen_height=height
|
||||
)
|
||||
if self.gpio_handler:
|
||||
logger.info("GPIO button handler created")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize GPIO buttons: {e}")
|
||||
elif gpio_config and not GPIO_BUTTONS_AVAILABLE:
|
||||
logger.info("GPIO buttons configured but RPi.GPIO not available (not on Raspberry Pi)")
|
||||
|
||||
self._initialized = False
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize all hardware components.
|
||||
|
||||
This initializes:
|
||||
- E-ink display controller
|
||||
- Touch sensor
|
||||
- Accelerometer (if enabled)
|
||||
- RTC (if enabled)
|
||||
- Power monitor (if enabled)
|
||||
- GPIO buttons (if configured)
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.warning("Hardware HAL already initialized")
|
||||
return
|
||||
|
||||
logger.info("Initializing hardware components...")
|
||||
await self.hal.initialize()
|
||||
|
||||
# Initialize GPIO buttons
|
||||
if self.gpio_handler:
|
||||
logger.info("Initializing GPIO buttons...")
|
||||
await self.gpio_handler.initialize()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Hardware HAL initialized successfully")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up all hardware resources."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up hardware HAL")
|
||||
|
||||
# Clean up GPIO buttons
|
||||
if self.gpio_handler:
|
||||
logger.info("Cleaning up GPIO buttons...")
|
||||
await self.gpio_handler.cleanup()
|
||||
|
||||
await self.hal.cleanup()
|
||||
self._initialized = False
|
||||
logger.info("Hardware HAL cleaned up")
|
||||
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display a PIL Image on the e-ink screen.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
|
||||
The dreader-hal library handles:
|
||||
- Format conversion (RGB -> grayscale)
|
||||
- Dithering for e-ink
|
||||
- Refresh mode selection (auto, fast, quality, full)
|
||||
- Orientation rotation (if enabled)
|
||||
"""
|
||||
if not self._initialized:
|
||||
logger.warning("Hardware HAL not initialized, initializing now...")
|
||||
await self.initialize()
|
||||
|
||||
logger.debug(f"Displaying image: {image.size} {image.mode}")
|
||||
await self.hal.show_image(image)
|
||||
|
||||
async def get_touch_event(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Get the next touch event from hardware (touch sensor or GPIO buttons).
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None if no event
|
||||
|
||||
The dreader-hal library handles gesture classification:
|
||||
- TAP: Quick tap (< 30px movement, < 300ms)
|
||||
- LONG_PRESS: Hold (< 30px movement, >= 500ms)
|
||||
- SWIPE_*: Directional swipes (>= 30px movement)
|
||||
- PINCH_IN/OUT: Two-finger pinch gestures
|
||||
|
||||
GPIO buttons are also polled and generate TouchEvent objects.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
# Check GPIO buttons first (they're more responsive)
|
||||
if self.gpio_handler:
|
||||
button_event = await self.gpio_handler.get_button_event()
|
||||
if button_event:
|
||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
||||
return button_event
|
||||
|
||||
# Get event from dreader-hal touch sensor
|
||||
hal_event = await self.hal.get_touch_event()
|
||||
|
||||
if hal_event is None:
|
||||
return None
|
||||
|
||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
||||
|
||||
if app_gesture is None:
|
||||
logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
||||
|
||||
return AppTouchEvent(
|
||||
gesture=app_gesture,
|
||||
x=hal_event.x,
|
||||
y=hal_event.y
|
||||
)
|
||||
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness.
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10)
|
||||
|
||||
Note:
|
||||
Basic IT8951 e-ink displays don't have brightness control.
|
||||
This is a no-op unless frontlight hardware is connected.
|
||||
"""
|
||||
if not 0 <= level <= 10:
|
||||
raise ValueError("Brightness must be 0-10")
|
||||
|
||||
logger.debug(f"Setting brightness to {level}")
|
||||
await self.hal.set_brightness(level)
|
||||
|
||||
# ========== Extended Methods (Hardware-Specific Features) ==========
|
||||
|
||||
async def get_battery_level(self) -> float:
|
||||
"""
|
||||
Get battery percentage.
|
||||
|
||||
Returns:
|
||||
Battery level 0-100%, or 0.0 if power monitor unavailable
|
||||
"""
|
||||
if not self._initialized:
|
||||
return 0.0
|
||||
|
||||
return await self.hal.get_battery_level()
|
||||
|
||||
async def get_power_stats(self) -> PowerStats:
|
||||
"""
|
||||
Get detailed power statistics.
|
||||
|
||||
Returns:
|
||||
PowerStats with voltage, current, power, battery %, etc.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If power monitor not enabled
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Hardware HAL not initialized")
|
||||
|
||||
return await self.hal.get_power_stats()
|
||||
|
||||
async def is_low_battery(self, threshold: float = 20.0) -> bool:
|
||||
"""
|
||||
Check if battery is low.
|
||||
|
||||
Args:
|
||||
threshold: Battery percentage threshold (default 20%)
|
||||
|
||||
Returns:
|
||||
True if battery below threshold, False otherwise
|
||||
"""
|
||||
if not self._initialized:
|
||||
return False
|
||||
|
||||
return await self.hal.is_low_battery(threshold)
|
||||
|
||||
async def set_low_power_mode(self, enabled: bool):
|
||||
"""
|
||||
Enable/disable low power mode.
|
||||
|
||||
In low power mode:
|
||||
- Display goes to sleep
|
||||
- Touch polling rate reduced
|
||||
- Sensors put to low power
|
||||
|
||||
Args:
|
||||
enabled: True to enable low power mode
|
||||
"""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info(f"Setting low power mode: {enabled}")
|
||||
await self.hal.set_low_power_mode(enabled)
|
||||
|
||||
async def enable_orientation_monitoring(self):
|
||||
"""
|
||||
Start monitoring device orientation changes.
|
||||
|
||||
When orientation changes, display auto-rotates.
|
||||
"""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Enabling orientation monitoring")
|
||||
await self.hal.enable_orientation_monitoring()
|
||||
|
||||
async def disable_orientation_monitoring(self):
|
||||
"""Stop monitoring orientation changes."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Disabling orientation monitoring")
|
||||
await self.hal.disable_orientation_monitoring()
|
||||
|
||||
@property
|
||||
def current_orientation(self) -> Optional[Orientation]:
|
||||
"""Get current device orientation."""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
return self.hal.current_orientation
|
||||
|
||||
@property
|
||||
def refresh_count(self) -> int:
|
||||
"""Get number of display refreshes since initialization."""
|
||||
if not self._initialized:
|
||||
return 0
|
||||
|
||||
return self.hal.refresh_count
|
||||
|
||||
async def get_datetime(self):
|
||||
"""
|
||||
Get current date/time from RTC.
|
||||
|
||||
Returns:
|
||||
struct_time with current date and time, or None if RTC unavailable
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
return await self.hal.get_datetime()
|
||||
|
||||
async def set_datetime(self, dt):
|
||||
"""
|
||||
Set the RTC date/time.
|
||||
|
||||
Args:
|
||||
dt: time.struct_time object with date and time to set
|
||||
|
||||
Raises:
|
||||
RuntimeError: If RTC not enabled
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Hardware HAL not initialized")
|
||||
|
||||
await self.hal.set_datetime(dt)
|
||||
|
||||
# ========== Accelerometer Tilt Detection ==========
|
||||
|
||||
def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool:
|
||||
"""
|
||||
Load accelerometer calibration from file.
|
||||
|
||||
Args:
|
||||
config_path: Path to calibration JSON file
|
||||
|
||||
Returns:
|
||||
True if calibration loaded successfully, False otherwise
|
||||
"""
|
||||
config_file = Path(config_path)
|
||||
if not config_file.exists():
|
||||
logger.warning(f"Accelerometer calibration file not found: {config_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Load up vector
|
||||
up = config.get("up_vector", {})
|
||||
self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
|
||||
|
||||
# Load thresholds
|
||||
self.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
|
||||
self.accel_debounce_time = config.get("debounce_time", 0.5)
|
||||
|
||||
# State tracking
|
||||
self.accel_last_tilt_time = 0
|
||||
|
||||
logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}")
|
||||
logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)")
|
||||
logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading accelerometer calibration: {e}")
|
||||
return False
|
||||
|
||||
async def get_tilt_gesture(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Check accelerometer for tilt gestures.
|
||||
|
||||
Returns:
|
||||
TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected,
|
||||
None otherwise
|
||||
|
||||
Note:
|
||||
Requires accelerometer calibration to be loaded first via
|
||||
load_accelerometer_calibration()
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
if not self.hal.orientation:
|
||||
return None
|
||||
|
||||
if not hasattr(self, 'accel_up_vector'):
|
||||
return None
|
||||
|
||||
# Get current acceleration
|
||||
try:
|
||||
ax, ay, az = await self.hal.orientation.get_acceleration()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading accelerometer: {e}")
|
||||
return None
|
||||
|
||||
# Check debounce
|
||||
current_time = time.time()
|
||||
if current_time - self.accel_last_tilt_time < self.accel_debounce_time:
|
||||
return None
|
||||
|
||||
# Calculate angle between current gravity and calibrated "up" vector
|
||||
# Gravity vector is the acceleration (pointing down)
|
||||
gx, gy, gz = ax, ay, az
|
||||
|
||||
# Normalize gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
if g_mag < 0.1:
|
||||
return None
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Normalize up vector
|
||||
ux, uy, uz = self.accel_up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
if u_mag < 0.1:
|
||||
return None
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
# Calculate tilt: project gravity onto the "forward/backward" axis
|
||||
# Forward/backward axis is perpendicular to up vector
|
||||
# We'll use the component of gravity that's perpendicular to the up vector
|
||||
|
||||
# Dot product: component of gravity along up vector
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
# Component of gravity perpendicular to up vector
|
||||
perp_x = gx - dot_up * ux
|
||||
perp_y = gy - dot_up * uy
|
||||
perp_z = gz - dot_up * uz
|
||||
|
||||
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
|
||||
|
||||
# Angle from vertical (in radians)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)")
|
||||
|
||||
# Check if tilted beyond threshold
|
||||
if tilt_angle < self.accel_tilt_threshold:
|
||||
return None
|
||||
|
||||
# Determine direction: forward or backward
|
||||
# We need to determine which direction the device is tilted
|
||||
# Use the sign of the perpendicular component along a reference axis
|
||||
|
||||
# For simplicity, we'll use the projection onto the original up vector's
|
||||
# perpendicular plane. If we tilt "forward", the gravity vector should
|
||||
# rotate in a specific direction.
|
||||
|
||||
# Calculate which direction: check if tilting away from or toward the up vector
|
||||
# If dot_up is decreasing (device tilting away from up), that's "forward"
|
||||
# If dot_up is increasing (device tilting back toward up), that's "backward"
|
||||
|
||||
# Actually, a simpler approach: check the direction of the perpendicular component
|
||||
# relative to a reference direction in the plane
|
||||
|
||||
# Let's define forward as tilting in the direction that increases the
|
||||
# y-component of acceleration (assuming standard orientation)
|
||||
# This is device-specific and may need adjustment
|
||||
|
||||
# For now, use a simple heuristic: forward = positive perpendicular y component
|
||||
if perp_y > 0:
|
||||
gesture = AppGestureType.TILT_FORWARD
|
||||
else:
|
||||
gesture = AppGestureType.TILT_BACKWARD
|
||||
|
||||
# Update debounce timer
|
||||
self.accel_last_tilt_time = current_time
|
||||
|
||||
logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)")
|
||||
|
||||
# Return gesture at center of screen (x, y don't matter for tilt)
|
||||
return AppTouchEvent(
|
||||
gesture=gesture,
|
||||
x=self.width // 2,
|
||||
y=self.height // 2,
|
||||
timestamp_ms=current_time * 1000
|
||||
)
|
||||
|
||||
async def get_event(self) -> Optional[AppTouchEvent]:
|
||||
"""
|
||||
Get the next event from any input source (GPIO, touch, or accelerometer).
|
||||
|
||||
This is a convenience method that polls all input sources in a single call.
|
||||
Priority order: GPIO buttons > touch sensor > accelerometer tilt
|
||||
|
||||
Returns:
|
||||
TouchEvent from GPIO, touch sensor, or accelerometer, or None if no event
|
||||
|
||||
Usage:
|
||||
while running:
|
||||
event = await hal.get_event()
|
||||
if event:
|
||||
handle_gesture(event)
|
||||
await asyncio.sleep(0.01)
|
||||
"""
|
||||
# Check GPIO buttons first (most responsive)
|
||||
if self.gpio_handler:
|
||||
button_event = await self.gpio_handler.get_button_event()
|
||||
if button_event:
|
||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
||||
return button_event
|
||||
|
||||
# Check touch sensor (second priority)
|
||||
# Get event from dreader-hal touch sensor directly
|
||||
hal_event = await self.hal.get_touch_event()
|
||||
if hal_event is not None:
|
||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
||||
if app_gesture is not None:
|
||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
||||
return AppTouchEvent(
|
||||
gesture=app_gesture,
|
||||
x=hal_event.x,
|
||||
y=hal_event.y
|
||||
)
|
||||
|
||||
# Check accelerometer tilt (lowest priority)
|
||||
if hasattr(self, 'accel_up_vector'):
|
||||
tilt_event = await self.get_tilt_gesture()
|
||||
if tilt_event:
|
||||
return tilt_event
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Pygame-based Display HAL for desktop testing.
|
||||
|
||||
This HAL implementation uses Pygame to provide a desktop window
|
||||
for testing the e-reader application without physical hardware.
|
||||
|
||||
Features:
|
||||
- Window display with PIL image rendering
|
||||
- Mouse input converted to touch events
|
||||
- Keyboard shortcuts for common actions
|
||||
- Gesture detection (swipes via mouse drag)
|
||||
|
||||
Usage:
|
||||
from dreader.hal_pygame import PygameDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
hal = PygameDisplayHAL(width=800, height=1200)
|
||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
||||
app = DReaderApplication(config)
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from .hal import EventLoopHAL
|
||||
from .gesture import TouchEvent, GestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pygame is optional - only needed for desktop testing
|
||||
try:
|
||||
import pygame
|
||||
PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYGAME_AVAILABLE = False
|
||||
logger.warning("Pygame not available. Install with: pip install pygame")
|
||||
|
||||
|
||||
class PygameDisplayHAL(EventLoopHAL):
|
||||
"""
|
||||
Pygame-based display HAL for desktop testing.
|
||||
|
||||
This implementation provides a desktop window that simulates
|
||||
an e-reader display with touch input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 800,
|
||||
height: int = 1200,
|
||||
title: str = "DReader E-Book Reader",
|
||||
fullscreen: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize Pygame display.
|
||||
|
||||
Args:
|
||||
width: Window width in pixels
|
||||
height: Window height in pixels
|
||||
title: Window title
|
||||
fullscreen: If True, open in fullscreen mode
|
||||
"""
|
||||
if not PYGAME_AVAILABLE:
|
||||
raise RuntimeError("Pygame is required for PygameDisplayHAL. Install with: pip install pygame")
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.title = title
|
||||
self.fullscreen = fullscreen
|
||||
|
||||
self.screen = None
|
||||
self.running = False
|
||||
|
||||
# Touch/gesture tracking
|
||||
self.mouse_down_pos: Optional[tuple[int, int]] = None
|
||||
self.mouse_down_time: float = 0
|
||||
self.drag_threshold = 20 # pixels (reduced from 30 for easier swiping)
|
||||
self.long_press_duration = 0.5 # seconds
|
||||
|
||||
logger.info(f"PygameDisplayHAL initialized: {width}x{height}")
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize Pygame and create window."""
|
||||
logger.info("Initializing Pygame")
|
||||
pygame.init()
|
||||
|
||||
# Set up display
|
||||
flags = pygame.DOUBLEBUF
|
||||
if self.fullscreen:
|
||||
flags |= pygame.FULLSCREEN
|
||||
|
||||
self.screen = pygame.display.set_mode((self.width, self.height), flags)
|
||||
pygame.display.set_caption(self.title)
|
||||
|
||||
# Set up font for messages
|
||||
pygame.font.init()
|
||||
|
||||
logger.info("Pygame initialized successfully")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Pygame resources."""
|
||||
logger.info("Cleaning up Pygame")
|
||||
if pygame.get_init():
|
||||
pygame.quit()
|
||||
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display PIL image on Pygame window.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
"""
|
||||
if not self.screen:
|
||||
logger.warning("Screen not initialized")
|
||||
return
|
||||
|
||||
# Convert PIL image to pygame surface
|
||||
# PIL uses RGB, pygame uses RGB
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize if needed
|
||||
if image.size != (self.width, self.height):
|
||||
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to numpy array, then to pygame surface
|
||||
img_array = np.array(image)
|
||||
surface = pygame.surfarray.make_surface(np.transpose(img_array, (1, 0, 2)))
|
||||
|
||||
# Blit to screen
|
||||
self.screen.blit(surface, (0, 0))
|
||||
pygame.display.flip()
|
||||
|
||||
# Small delay to prevent excessive CPU usage
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Process pygame events and convert to TouchEvent.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None otherwise
|
||||
"""
|
||||
if not pygame.get_init():
|
||||
return None
|
||||
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
logger.info("Quit event received")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN:
|
||||
# Mouse down - start tracking for gesture
|
||||
self.mouse_down_pos = event.pos
|
||||
self.mouse_down_time = pygame.time.get_ticks() / 1000.0
|
||||
logger.info(f"[MOUSE] Button DOWN at {event.pos}")
|
||||
|
||||
elif event.type == pygame.MOUSEMOTION:
|
||||
# Show drag indicator while mouse is down
|
||||
if self.mouse_down_pos and pygame.mouse.get_pressed()[0]:
|
||||
current_pos = event.pos
|
||||
dx = current_pos[0] - self.mouse_down_pos[0]
|
||||
dy = current_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
|
||||
# Log dragging in progress
|
||||
if distance > 5: # Log any significant drag
|
||||
logger.info(f"[DRAG] Moving: dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px")
|
||||
|
||||
# Only show if dragging beyond threshold
|
||||
if distance > self.drag_threshold:
|
||||
# Draw a line showing the swipe direction
|
||||
if self.screen:
|
||||
# This is just for visual feedback during drag
|
||||
# The actual gesture detection happens on mouse up
|
||||
pass
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONUP:
|
||||
if self.mouse_down_pos is None:
|
||||
logger.warning("[MOUSE] Button UP but no down position recorded")
|
||||
continue
|
||||
|
||||
mouse_up_pos = event.pos
|
||||
mouse_up_time = pygame.time.get_ticks() / 1000.0
|
||||
|
||||
# Calculate distance and time
|
||||
dx = mouse_up_pos[0] - self.mouse_down_pos[0]
|
||||
dy = mouse_up_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
duration = mouse_up_time - self.mouse_down_time
|
||||
|
||||
logger.info(f"[MOUSE] Button UP at {mouse_up_pos}")
|
||||
logger.info(f"[GESTURE] dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px, duration={duration:.2f}s, threshold={self.drag_threshold}px")
|
||||
|
||||
# Detect gesture type
|
||||
gesture = None
|
||||
# For swipe gestures, use the starting position (mouse_down_pos)
|
||||
# For tap/long-press, use the ending position (mouse_up_pos)
|
||||
x, y = mouse_up_pos
|
||||
|
||||
if distance < self.drag_threshold:
|
||||
# Tap or long press
|
||||
if duration >= self.long_press_duration:
|
||||
gesture = GestureType.LONG_PRESS
|
||||
logger.info(f"[GESTURE] ✓ Detected: LONG_PRESS")
|
||||
else:
|
||||
gesture = GestureType.TAP
|
||||
logger.info(f"[GESTURE] ✓ Detected: TAP")
|
||||
else:
|
||||
# Swipe - use starting position for location-based checks
|
||||
x, y = self.mouse_down_pos
|
||||
if abs(dx) > abs(dy):
|
||||
# Horizontal swipe
|
||||
if dx > 0:
|
||||
gesture = GestureType.SWIPE_RIGHT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_RIGHT (dx={dx:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_LEFT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_LEFT (dx={dx:.0f})")
|
||||
else:
|
||||
# Vertical swipe
|
||||
if dy > 0:
|
||||
gesture = GestureType.SWIPE_DOWN
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_DOWN (dy={dy:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_UP
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_UP (dy={dy:.0f})")
|
||||
|
||||
# Reset tracking
|
||||
self.mouse_down_pos = None
|
||||
|
||||
if gesture:
|
||||
# For swipe gestures, (x,y) is the start position
|
||||
# For tap/long-press, (x,y) is the tap position
|
||||
logger.info(f"[EVENT] Returning TouchEvent: {gesture.value} at ({x}, {y})")
|
||||
return TouchEvent(gesture, x, y)
|
||||
else:
|
||||
logger.warning("[EVENT] No gesture detected (should not happen)")
|
||||
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
# Keyboard shortcuts
|
||||
return await self._handle_keyboard(event)
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_keyboard(self, event) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Handle keyboard shortcuts.
|
||||
|
||||
Args:
|
||||
event: Pygame keyboard event
|
||||
|
||||
Returns:
|
||||
TouchEvent equivalent of keyboard action
|
||||
"""
|
||||
# Arrow keys for page navigation
|
||||
if event.key == pygame.K_LEFT or event.key == pygame.K_PAGEUP:
|
||||
# Previous page
|
||||
return TouchEvent(GestureType.SWIPE_RIGHT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_RIGHT or event.key == pygame.K_PAGEDOWN or event.key == pygame.K_SPACE:
|
||||
# Next page
|
||||
return TouchEvent(GestureType.SWIPE_LEFT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_UP:
|
||||
# Scroll up (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_DOWN, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_DOWN:
|
||||
# Scroll down (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_UP, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
|
||||
# Quit
|
||||
logger.info("Quit via keyboard")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.key == pygame.K_EQUALS or event.key == pygame.K_PLUS:
|
||||
# Zoom in (pinch out)
|
||||
return TouchEvent(GestureType.PINCH_OUT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_MINUS:
|
||||
# Zoom out (pinch in)
|
||||
return TouchEvent(GestureType.PINCH_IN, self.width // 2, self.height // 2)
|
||||
|
||||
return None
|
||||
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness (not supported in Pygame).
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10)
|
||||
|
||||
Note: Brightness control is not available in Pygame.
|
||||
This is a no-op for desktop testing.
|
||||
"""
|
||||
logger.debug(f"Brightness set to {level} (not supported in Pygame)")
|
||||
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the Pygame event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance
|
||||
|
||||
This method:
|
||||
1. Initializes Pygame
|
||||
2. Starts the application
|
||||
3. Runs the event loop
|
||||
4. Handles events and updates display
|
||||
5. Shuts down gracefully
|
||||
"""
|
||||
logger.info("Starting Pygame event loop")
|
||||
|
||||
try:
|
||||
# Initialize
|
||||
await self.initialize()
|
||||
await app.start()
|
||||
|
||||
self.running = True
|
||||
|
||||
# Show instructions
|
||||
await self._show_instructions()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Main event loop
|
||||
clock = pygame.time.Clock()
|
||||
|
||||
while self.running and app.is_running():
|
||||
# Process events
|
||||
touch_event = await self.get_touch_event()
|
||||
|
||||
if touch_event:
|
||||
# Handle touch event
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
# Cap frame rate
|
||||
clock.tick(60) # 60 FPS max
|
||||
await asyncio.sleep(0.001) # Yield to other async tasks
|
||||
|
||||
logger.info("Event loop ended")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event loop: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await app.shutdown()
|
||||
await self.cleanup()
|
||||
|
||||
async def _show_instructions(self):
|
||||
"""Show keyboard instructions overlay."""
|
||||
if not self.screen:
|
||||
return
|
||||
|
||||
# Create instruction text
|
||||
font = pygame.font.Font(None, 24)
|
||||
instructions = [
|
||||
"DReader E-Book Reader",
|
||||
"",
|
||||
"Mouse Gestures:",
|
||||
" Drag LEFT (horizontal) = Next Page",
|
||||
" Drag RIGHT (horizontal) = Previous Page*",
|
||||
" Drag UP (vertical) = Navigation/TOC Overlay",
|
||||
" Drag DOWN (vertical) = Settings Overlay",
|
||||
"",
|
||||
"Keyboard Shortcuts:",
|
||||
" Space / Right Arrow = Next Page",
|
||||
" Left Arrow = Previous Page*",
|
||||
" +/- = Font Size",
|
||||
" Q/Escape = Quit",
|
||||
"",
|
||||
"*Previous page not working (pyWebLayout bug)",
|
||||
"",
|
||||
"Press any key to start..."
|
||||
]
|
||||
|
||||
# Create semi-transparent overlay
|
||||
overlay = pygame.Surface((self.width, self.height))
|
||||
overlay.fill((255, 255, 255))
|
||||
overlay.set_alpha(230)
|
||||
|
||||
# Render text
|
||||
y = 100
|
||||
for line in instructions:
|
||||
if line:
|
||||
text = font.render(line, True, (0, 0, 0))
|
||||
else:
|
||||
text = pygame.Surface((1, 20)) # Empty line
|
||||
text_rect = text.get_rect(center=(self.width // 2, y))
|
||||
overlay.blit(text, text_rect)
|
||||
y += 30
|
||||
|
||||
# Display
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
pygame.display.flip()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Handlers module for dreader application.
|
||||
|
||||
This module contains interaction handlers:
|
||||
- GestureRouter: Routes touch events to appropriate handlers
|
||||
"""
|
||||
|
||||
from .gestures import GestureRouter
|
||||
|
||||
__all__ = ['GestureRouter']
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Gesture routing and handling.
|
||||
|
||||
This module handles all touch event routing and gesture logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from ..gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
from pyWebLayout.core.query import SelectionRange
|
||||
|
||||
|
||||
class GestureRouter:
|
||||
"""
|
||||
Routes and handles all gestures.
|
||||
|
||||
This class centralizes all gesture handling logic, making it easier
|
||||
to test and maintain gesture interactions.
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""
|
||||
Initialize the gesture router.
|
||||
|
||||
Args:
|
||||
reader: EbookReader instance to route gestures for
|
||||
"""
|
||||
self.reader = reader
|
||||
|
||||
# Selection state (for text selection gestures)
|
||||
self._selection_start: Optional[Tuple[int, int]] = None
|
||||
self._selection_end: Optional[Tuple[int, int]] = None
|
||||
self._selected_range: Optional['SelectionRange'] = None
|
||||
|
||||
def handle_touch(self, event: TouchEvent) -> GestureResponse:
|
||||
"""
|
||||
Handle a touch event from HAL.
|
||||
|
||||
This is the main entry point for all touch interactions.
|
||||
|
||||
Args:
|
||||
event: TouchEvent from HAL with gesture type and coordinates
|
||||
|
||||
Returns:
|
||||
GestureResponse with action and data for UI to process
|
||||
"""
|
||||
if not self.reader.is_loaded():
|
||||
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
|
||||
|
||||
# Handle overlay-specific gestures first
|
||||
if self.reader.is_overlay_open():
|
||||
if event.gesture == GestureType.TAP:
|
||||
return self._handle_overlay_tap(event.x, event.y)
|
||||
elif event.gesture == GestureType.SWIPE_DOWN:
|
||||
return self._handle_overlay_close()
|
||||
|
||||
# Dispatch based on gesture type for normal reading mode
|
||||
if event.gesture == GestureType.TAP:
|
||||
return self._handle_tap(event.x, event.y)
|
||||
elif event.gesture == GestureType.LONG_PRESS:
|
||||
return self._handle_long_press(event.x, event.y)
|
||||
elif event.gesture == GestureType.SWIPE_LEFT:
|
||||
return self._handle_page_forward()
|
||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||
return self._handle_page_back()
|
||||
elif event.gesture == GestureType.SWIPE_UP:
|
||||
return self._handle_swipe_up(event.y)
|
||||
elif event.gesture == GestureType.SWIPE_DOWN:
|
||||
return self._handle_swipe_down(event.y)
|
||||
elif event.gesture == GestureType.PINCH_IN:
|
||||
return self._handle_zoom_out()
|
||||
elif event.gesture == GestureType.PINCH_OUT:
|
||||
return self._handle_zoom_in()
|
||||
elif event.gesture == GestureType.DRAG_START:
|
||||
return self._handle_selection_start(event.x, event.y)
|
||||
elif event.gesture == GestureType.DRAG_MOVE:
|
||||
return self._handle_selection_move(event.x, event.y)
|
||||
elif event.gesture == GestureType.DRAG_END:
|
||||
return self._handle_selection_end(event.x, event.y)
|
||||
elif event.gesture == GestureType.TILT_FORWARD:
|
||||
return self._handle_page_forward()
|
||||
elif event.gesture == GestureType.TILT_BACKWARD:
|
||||
return self._handle_page_back()
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
# ===================================================================
|
||||
# Reading Mode Gesture Handlers
|
||||
# ===================================================================
|
||||
|
||||
def _handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle tap gesture - activates links or selects words"""
|
||||
page = self.reader.manager.get_current_page()
|
||||
result = page.query_point((x, y))
|
||||
|
||||
if not result or result.object_type == "empty":
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
# If it's a link, navigate
|
||||
if result.is_interactive and result.link_target:
|
||||
# Handle different link types
|
||||
if result.link_target.endswith('.epub'):
|
||||
# Open new book
|
||||
success = self.reader.load_epub(result.link_target)
|
||||
if success:
|
||||
return GestureResponse(ActionType.BOOK_LOADED, {
|
||||
"title": self.reader.book_title,
|
||||
"author": self.reader.book_author,
|
||||
"path": result.link_target
|
||||
})
|
||||
else:
|
||||
return GestureResponse(ActionType.ERROR, {
|
||||
"message": f"Failed to load {result.link_target}"
|
||||
})
|
||||
else:
|
||||
# Internal navigation (chapter)
|
||||
self.reader.jump_to_chapter(result.link_target)
|
||||
return GestureResponse(ActionType.NAVIGATE, {
|
||||
"target": result.link_target,
|
||||
"chapter": self.reader.get_current_chapter_info()
|
||||
})
|
||||
|
||||
# Just a tap on text - select word
|
||||
if result.text:
|
||||
return GestureResponse(ActionType.WORD_SELECTED, {
|
||||
"word": result.text,
|
||||
"bounds": result.bounds
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_long_press(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle long-press - show definition or menu"""
|
||||
page = self.reader.manager.get_current_page()
|
||||
result = page.query_point((x, y))
|
||||
|
||||
if result and result.text:
|
||||
return GestureResponse(ActionType.DEFINE, {
|
||||
"word": result.text,
|
||||
"bounds": result.bounds
|
||||
})
|
||||
|
||||
# Long-press on empty - show menu
|
||||
return GestureResponse(ActionType.SHOW_MENU, {
|
||||
"options": ["bookmark", "settings", "toc", "search"]
|
||||
})
|
||||
|
||||
def _handle_page_forward(self) -> GestureResponse:
|
||||
"""Handle swipe left - next page"""
|
||||
img = self.reader.next_page()
|
||||
if img:
|
||||
return GestureResponse(ActionType.PAGE_TURN, {
|
||||
"direction": "forward",
|
||||
"progress": self.reader.get_reading_progress(),
|
||||
"chapter": self.reader.get_current_chapter_info()
|
||||
})
|
||||
return GestureResponse(ActionType.AT_END, {})
|
||||
|
||||
def _handle_page_back(self) -> GestureResponse:
|
||||
"""Handle swipe right - previous page"""
|
||||
img = self.reader.previous_page()
|
||||
if img:
|
||||
return GestureResponse(ActionType.PAGE_TURN, {
|
||||
"direction": "back",
|
||||
"progress": self.reader.get_reading_progress(),
|
||||
"chapter": self.reader.get_current_chapter_info()
|
||||
})
|
||||
return GestureResponse(ActionType.AT_START, {})
|
||||
|
||||
def _handle_zoom_in(self) -> GestureResponse:
|
||||
"""Handle pinch out - increase font"""
|
||||
self.reader.increase_font_size()
|
||||
return GestureResponse(ActionType.ZOOM, {
|
||||
"direction": "in",
|
||||
"font_scale": self.reader.base_font_scale
|
||||
})
|
||||
|
||||
def _handle_zoom_out(self) -> GestureResponse:
|
||||
"""Handle pinch in - decrease font"""
|
||||
self.reader.decrease_font_size()
|
||||
return GestureResponse(ActionType.ZOOM, {
|
||||
"direction": "out",
|
||||
"font_scale": self.reader.base_font_scale
|
||||
})
|
||||
|
||||
def _handle_selection_start(self, x: int, y: int) -> GestureResponse:
|
||||
"""Start text selection"""
|
||||
self._selection_start = (x, y)
|
||||
self._selection_end = None
|
||||
self._selected_range = None
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_START, {
|
||||
"start": (x, y)
|
||||
})
|
||||
|
||||
def _handle_selection_move(self, x: int, y: int) -> GestureResponse:
|
||||
"""Update text selection"""
|
||||
if not self._selection_start:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
self._selection_end = (x, y)
|
||||
|
||||
# Query range
|
||||
page = self.reader.manager.get_current_page()
|
||||
self._selected_range = page.query_range(
|
||||
self._selection_start,
|
||||
self._selection_end
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_UPDATE, {
|
||||
"start": self._selection_start,
|
||||
"end": self._selection_end,
|
||||
"text_count": len(self._selected_range.results),
|
||||
"bounds": self._selected_range.bounds_list
|
||||
})
|
||||
|
||||
def _handle_selection_end(self, x: int, y: int) -> GestureResponse:
|
||||
"""End text selection and return selected text"""
|
||||
if not self._selection_start:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
self._selection_end = (x, y)
|
||||
|
||||
page = self.reader.manager.get_current_page()
|
||||
self._selected_range = page.query_range(
|
||||
self._selection_start,
|
||||
self._selection_end
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SELECTION_COMPLETE, {
|
||||
"text": self._selected_range.text,
|
||||
"word_count": len(self._selected_range.results),
|
||||
"bounds": self._selected_range.bounds_list
|
||||
})
|
||||
|
||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
|
||||
# Open navigation overlay from anywhere on screen
|
||||
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "navigation",
|
||||
"active_tab": "contents",
|
||||
"chapters": self.reader.get_chapters()
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
||||
"""Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)"""
|
||||
# Only open settings overlay if swipe starts from top 20% of screen
|
||||
top_threshold = self.reader.page_size[1] * 0.2
|
||||
if y > top_threshold:
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "settings",
|
||||
"font_scale": self.reader.base_font_scale,
|
||||
"line_spacing": self.reader.page_style.line_spacing,
|
||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
# ===================================================================
|
||||
# Overlay Mode Gesture Handlers
|
||||
# ===================================================================
|
||||
|
||||
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle tap when overlay is open - delegates to EbookReader overlay handlers"""
|
||||
# This remains in EbookReader because it's tightly coupled with overlay state
|
||||
return self.reader._handle_overlay_tap(x, y)
|
||||
|
||||
def _handle_overlay_close(self) -> GestureResponse:
|
||||
"""Handle overlay close gesture (swipe down)"""
|
||||
self.reader.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
@@ -192,138 +192,141 @@ def generate_reader_html(book_title: str, book_author: str, page_image_data: str
|
||||
return html
|
||||
|
||||
|
||||
def generate_settings_overlay() -> str:
|
||||
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.
|
||||
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
|
||||
HTML string for settings overlay with clickable controls
|
||||
"""
|
||||
html = '''
|
||||
# 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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings</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: 400px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.settings-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.setting-label {
|
||||
font-weight: bold;
|
||||
width: 40%;
|
||||
}
|
||||
.setting-control {
|
||||
width: 60%;
|
||||
text-align: right;
|
||||
}
|
||||
.control-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.control-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-panel">
|
||||
<div class="overlay-header">
|
||||
<span class="overlay-title">Settings</span>
|
||||
<button class="close-button" id="btn-close">Close</button>
|
||||
</div>
|
||||
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
|
||||
|
||||
<table class="settings-table">
|
||||
<tr>
|
||||
<td class="setting-label">Font Size</td>
|
||||
<td class="setting-control">
|
||||
<button class="control-button" id="btn-font-decrease">A-</button>
|
||||
<button class="control-button" id="btn-font-increase">A+</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="setting-label">Line Spacing</td>
|
||||
<td class="setting-control">
|
||||
<button class="control-button" id="btn-spacing-decrease">-</button>
|
||||
<button class="control-button" id="btn-spacing-increase">+</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="setting-label">Brightness</td>
|
||||
<td class="setting-control">
|
||||
<button class="control-button" id="btn-brightness-decrease">-</button>
|
||||
<button class="control-button" id="btn-brightness-increase">+</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="setting-label">WiFi</td>
|
||||
<td class="setting-control">
|
||||
<button class="control-button" id="btn-wifi">Configure</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<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)) -> str:
|
||||
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.
|
||||
|
||||
@@ -332,21 +335,32 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
||||
- 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(chapters):
|
||||
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'{i+1}. {title}'
|
||||
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'{i+1}. {title} ' # Extra spaces for padding
|
||||
link_text = f'{chapter_num}. {title} ' # Extra spaces for padding
|
||||
|
||||
chapter_items.append(
|
||||
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||
@@ -355,6 +369,26 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
||||
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>
|
||||
@@ -374,10 +408,12 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
||||
{len(chapters)} chapters
|
||||
</p>
|
||||
|
||||
<div style="max-height: 600px; overflow-y: auto;">
|
||||
<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
|
||||
@@ -531,3 +567,197 @@ def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
|
||||
</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
|
||||
|
||||
@@ -10,6 +10,8 @@ Handles:
|
||||
|
||||
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
|
||||
@@ -29,6 +31,8 @@ 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:
|
||||
"""
|
||||
@@ -45,7 +49,8 @@ class LibraryManager:
|
||||
self,
|
||||
library_path: str,
|
||||
cache_dir: Optional[str] = None,
|
||||
page_size: Tuple[int, int] = (800, 1200)
|
||||
page_size: Tuple[int, int] = (800, 1200),
|
||||
books_per_page: int = 6
|
||||
):
|
||||
"""
|
||||
Initialize library manager.
|
||||
@@ -54,9 +59,11 @@ class LibraryManager:
|
||||
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:
|
||||
@@ -75,6 +82,7 @@ class LibraryManager:
|
||||
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:
|
||||
@@ -96,19 +104,34 @@ class LibraryManager:
|
||||
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
|
||||
for book in self.books:
|
||||
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
|
||||
|
||||
@@ -149,12 +172,13 @@ class LibraryManager:
|
||||
print(f"Error caching cover for {book['title']}: {e}")
|
||||
return None
|
||||
|
||||
def create_library_table(self, books: Optional[List[Dict]] = None) -> Table:
|
||||
def create_library_table(self, books: Optional[List[Dict]] = None, page: Optional[int] = None) -> Table:
|
||||
"""
|
||||
Create interactive library table with book covers and info.
|
||||
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
|
||||
@@ -162,83 +186,133 @@ class LibraryManager:
|
||||
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 = []
|
||||
|
||||
print(f"Creating library table with {len(books)} 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]
|
||||
|
||||
# Create table
|
||||
table = Table(caption="My Library", style=Font(font_size=18, weight="bold"))
|
||||
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
|
||||
|
||||
# Add books as rows
|
||||
for i, book in enumerate(books):
|
||||
row = table.create_row("body")
|
||||
# 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"))
|
||||
|
||||
# Cover cell with interactive image
|
||||
cover_cell = row.create_cell()
|
||||
cover_path = book.get('cover_path')
|
||||
book_path = book['path']
|
||||
# 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")
|
||||
|
||||
# Create callback that returns book path
|
||||
callback = lambda point, path=book_path: path
|
||||
# Add first book's cover (left column)
|
||||
self._add_book_cover(cover_row, page_books[i])
|
||||
|
||||
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)
|
||||
# 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:
|
||||
# No cover available
|
||||
self._add_no_cover_text(cover_cell)
|
||||
# Add empty cell if odd number of books
|
||||
cover_row.create_cell()
|
||||
|
||||
# Book info cell
|
||||
info_cell = row.create_cell()
|
||||
# Row 2: Details for this pair
|
||||
details_row = table.create_row("body")
|
||||
|
||||
# Title paragraph
|
||||
title_para = info_cell.create_paragraph()
|
||||
for word in book['title'].split():
|
||||
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
||||
# Add first book's details (left column)
|
||||
self._add_book_details(details_row, page_books[i])
|
||||
|
||||
# Author paragraph
|
||||
author_para = info_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 = info_cell.create_paragraph()
|
||||
filename_para.add_word(Word(
|
||||
Path(book['path']).name,
|
||||
Font(font_size=10, colour=(150, 150, 150))
|
||||
))
|
||||
# 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()
|
||||
@@ -255,15 +329,20 @@ class LibraryManager:
|
||||
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),
|
||||
@@ -273,6 +352,8 @@ class LibraryManager:
|
||||
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(
|
||||
@@ -289,6 +370,8 @@ class LibraryManager:
|
||||
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,
|
||||
@@ -298,18 +381,24 @@ class LibraryManager:
|
||||
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.
|
||||
Handle tap event on library view with 2-column grid.
|
||||
|
||||
Checks if the tap is within any row's bounds and returns the corresponding
|
||||
book path. This makes the entire row interactive, not just the cover image.
|
||||
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
|
||||
@@ -323,6 +412,11 @@ class LibraryManager:
|
||||
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())
|
||||
|
||||
@@ -345,11 +439,43 @@ class LibraryManager:
|
||||
# Find which body row this is (0-indexed)
|
||||
body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body")
|
||||
|
||||
# Return the corresponding book
|
||||
if body_row_index < len(self.books):
|
||||
book_path = self.books[body_row_index]['path']
|
||||
print(f"Book selected (row {body_row_index}): {book_path}")
|
||||
return book_path
|
||||
# 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
|
||||
@@ -374,6 +500,56 @@ class LibraryManager:
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Main application controller for DReader e-reader application.
|
||||
|
||||
This module provides the DReaderApplication class which orchestrates:
|
||||
- Library and reading mode transitions
|
||||
- State persistence and recovery
|
||||
- HAL integration for display and input
|
||||
- Event routing and handling
|
||||
|
||||
The application uses asyncio for non-blocking operations and integrates
|
||||
with a hardware abstraction layer (HAL) for platform independence.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
|
||||
from .library import LibraryManager
|
||||
from .application import EbookReader
|
||||
from .state import StateManager, EreaderMode, OverlayState, BookState
|
||||
from .gesture import TouchEvent, GestureType, ActionType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""
|
||||
Configuration for DReaderApplication.
|
||||
|
||||
Attributes:
|
||||
display_hal: Hardware abstraction layer for display/input
|
||||
library_path: Path to directory containing EPUB files
|
||||
page_size: Tuple of (width, height) for rendered pages
|
||||
bookmarks_dir: Directory for bookmark storage (default: ~/.config/dreader/bookmarks)
|
||||
highlights_dir: Directory for highlights storage (default: ~/.config/dreader/highlights)
|
||||
state_file: Path to state JSON file (default: ~/.config/dreader/state.json)
|
||||
auto_save_interval: Seconds between automatic state saves (default: 60)
|
||||
force_library_mode: If True, always start in library mode (default: False)
|
||||
log_level: Logging level (default: logging.INFO)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_hal,
|
||||
library_path: str,
|
||||
page_size: tuple[int, int] = (800, 1200),
|
||||
bookmarks_dir: Optional[str] = None,
|
||||
highlights_dir: Optional[str] = None,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save_interval: int = 60,
|
||||
force_library_mode: bool = False,
|
||||
log_level: int = logging.INFO
|
||||
):
|
||||
self.display_hal = display_hal
|
||||
self.library_path = library_path
|
||||
self.page_size = page_size
|
||||
self.force_library_mode = force_library_mode
|
||||
|
||||
# Set up default config paths
|
||||
config_dir = Path.home() / ".config" / "dreader"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.bookmarks_dir = bookmarks_dir or str(config_dir / "bookmarks")
|
||||
self.highlights_dir = highlights_dir or str(config_dir / "highlights")
|
||||
self.state_file = state_file or str(config_dir / "state.json")
|
||||
self.auto_save_interval = auto_save_interval
|
||||
self.log_level = log_level
|
||||
|
||||
|
||||
class DReaderApplication:
|
||||
"""
|
||||
Main application controller coordinating library and reading modes.
|
||||
|
||||
This class orchestrates all major components of the e-reader:
|
||||
- LibraryManager for book browsing
|
||||
- EbookReader for reading books
|
||||
- StateManager for persistence
|
||||
- DisplayHAL for hardware integration
|
||||
|
||||
Usage:
|
||||
config = AppConfig(
|
||||
display_hal=MyDisplayHAL(),
|
||||
library_path="/path/to/books"
|
||||
)
|
||||
|
||||
app = DReaderApplication(config)
|
||||
await app.start()
|
||||
|
||||
# In event loop:
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
await app.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(self, config: AppConfig):
|
||||
"""
|
||||
Initialize the application with configuration.
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=config.log_level)
|
||||
logger.info("Initializing DReaderApplication")
|
||||
|
||||
# State management
|
||||
self.state_manager = StateManager(
|
||||
state_file=config.state_file,
|
||||
auto_save_interval=config.auto_save_interval
|
||||
)
|
||||
self.state = self.state_manager.load_state()
|
||||
logger.info(f"Loaded state: mode={self.state.mode}, current_book={self.state.current_book}")
|
||||
|
||||
# Components (lazy-initialized)
|
||||
self.library: Optional[LibraryManager] = None
|
||||
self.reader: Optional[EbookReader] = None
|
||||
|
||||
# Display abstraction
|
||||
self.display_hal = config.display_hal
|
||||
self.current_image: Optional[Image.Image] = None
|
||||
|
||||
# Running state
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the application and display initial screen.
|
||||
|
||||
This method:
|
||||
1. Starts automatic state saving
|
||||
2. Restores previous mode or shows library
|
||||
3. Displays the initial screen
|
||||
"""
|
||||
logger.info("Starting DReaderApplication")
|
||||
self.running = True
|
||||
|
||||
# Start auto-save
|
||||
self.state_manager.start_auto_save()
|
||||
logger.info(f"Auto-save started (interval: {self.config.auto_save_interval}s)")
|
||||
|
||||
# Restore previous mode (or force library mode if configured)
|
||||
force_library = getattr(self.config, 'force_library_mode', False)
|
||||
|
||||
if force_library:
|
||||
logger.info("Force library mode enabled - starting in library")
|
||||
await self._enter_library_mode()
|
||||
elif self.state.mode == EreaderMode.READING and self.state.current_book:
|
||||
logger.info(f"Resuming reading mode: {self.state.current_book.path}")
|
||||
await self._enter_reading_mode(self.state.current_book.path)
|
||||
else:
|
||||
logger.info("Entering library mode")
|
||||
await self._enter_library_mode()
|
||||
|
||||
# Display initial screen
|
||||
await self._update_display()
|
||||
logger.info("Application started successfully")
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Gracefully shutdown the application.
|
||||
|
||||
This method:
|
||||
1. Saves current reading position
|
||||
2. Closes active components
|
||||
3. Stops auto-save and saves final state
|
||||
"""
|
||||
logger.info("Shutting down DReaderApplication")
|
||||
self.running = False
|
||||
|
||||
# Save current position if reading
|
||||
if self.reader and self.reader.is_loaded():
|
||||
logger.info("Saving auto-resume position")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
|
||||
# Clean up library
|
||||
if self.library:
|
||||
self.library.cleanup()
|
||||
|
||||
# Stop auto-save and save final state
|
||||
await self.state_manager.stop_auto_save(save_final=True)
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
async def handle_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Process touch event based on current mode.
|
||||
|
||||
Args:
|
||||
event: Touch event from HAL
|
||||
"""
|
||||
logger.info(f"[APP] Received touch event: {event.gesture.value} at ({event.x}, {event.y}), mode={self.state.mode.value}")
|
||||
|
||||
if self.state.mode == EreaderMode.LIBRARY:
|
||||
logger.info("[APP] Routing to library touch handler")
|
||||
await self._handle_library_touch(event)
|
||||
elif self.state.mode == EreaderMode.READING:
|
||||
logger.info("[APP] Routing to reading touch handler")
|
||||
await self._handle_reading_touch(event)
|
||||
|
||||
# Update display after handling
|
||||
await self._update_display()
|
||||
|
||||
async def _enter_library_mode(self):
|
||||
"""
|
||||
Switch to library browsing mode.
|
||||
|
||||
This method:
|
||||
1. Saves and closes reader if active
|
||||
2. Initializes library manager
|
||||
3. Renders library view
|
||||
4. Updates state
|
||||
"""
|
||||
logger.info("Entering library mode")
|
||||
|
||||
# Save and close reader if active
|
||||
if self.reader:
|
||||
if self.reader.is_loaded():
|
||||
logger.info("Saving reading position before closing")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
self.reader = None
|
||||
|
||||
# Initialize library if needed
|
||||
if not self.library:
|
||||
logger.info(f"Initializing library manager: {self.config.library_path}")
|
||||
self.library = LibraryManager(
|
||||
library_path=self.config.library_path,
|
||||
page_size=self.config.page_size,
|
||||
cache_dir=None # Uses default ~/.config/dreader
|
||||
)
|
||||
|
||||
# Scan for books (async operation)
|
||||
logger.info("Scanning library for books")
|
||||
books = self.library.scan_library()
|
||||
logger.info(f"Found {len(books)} books")
|
||||
|
||||
# Render library view
|
||||
logger.info("Rendering library view")
|
||||
self.current_image = self.library.render_library()
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_mode(EreaderMode.LIBRARY)
|
||||
logger.info("Library mode active")
|
||||
|
||||
async def _enter_reading_mode(self, book_path: str):
|
||||
"""
|
||||
Switch to reading mode.
|
||||
|
||||
Args:
|
||||
book_path: Path to EPUB file to open
|
||||
|
||||
This method:
|
||||
1. Initializes reader if needed
|
||||
2. Loads the book
|
||||
3. Applies saved settings
|
||||
4. Restores reading position
|
||||
5. Updates state
|
||||
6. Renders first/current page
|
||||
"""
|
||||
logger.info(f"Entering reading mode: {book_path}")
|
||||
|
||||
# Verify book exists
|
||||
if not Path(book_path).exists():
|
||||
logger.error(f"Book not found: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
# Initialize reader if needed
|
||||
if not self.reader:
|
||||
logger.info("Initializing ebook reader")
|
||||
self.reader = EbookReader(
|
||||
page_size=self.config.page_size,
|
||||
margin=40,
|
||||
background_color=(255, 255, 255),
|
||||
bookmarks_dir=self.config.bookmarks_dir,
|
||||
highlights_dir=self.config.highlights_dir
|
||||
)
|
||||
|
||||
# Load book
|
||||
logger.info(f"Loading EPUB: {book_path}")
|
||||
success = self.reader.load_epub(book_path)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Failed to load EPUB: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
logger.info(f"Loaded: {self.reader.book_title} by {self.reader.book_author}")
|
||||
|
||||
# Apply saved settings
|
||||
logger.info("Applying saved settings")
|
||||
settings_dict = self.state.settings.to_dict()
|
||||
self.reader.apply_settings(settings_dict)
|
||||
|
||||
# Restore position
|
||||
logger.info("Restoring reading position")
|
||||
position_loaded = self.reader.load_position("__auto_resume__")
|
||||
if position_loaded:
|
||||
pos_info = self.reader.get_position_info()
|
||||
logger.info(f"Resumed at position: {pos_info}")
|
||||
else:
|
||||
logger.info("No saved position, starting from beginning")
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_current_book(BookState(
|
||||
path=book_path,
|
||||
title=self.reader.book_title or "Unknown",
|
||||
author=self.reader.book_author or "Unknown"
|
||||
))
|
||||
self.state_manager.set_mode(EreaderMode.READING)
|
||||
|
||||
# Render current page
|
||||
logger.info("Rendering current page")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
logger.info("Reading mode active")
|
||||
|
||||
async def _handle_library_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in library mode.
|
||||
|
||||
Supports:
|
||||
- TAP: Select a book to read
|
||||
- SWIPE_LEFT: Next page
|
||||
- SWIPE_RIGHT: Previous page
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
if event.gesture == GestureType.TAP:
|
||||
logger.debug(f"Library tap at ({event.x}, {event.y})")
|
||||
|
||||
# Check if a book was selected
|
||||
book_path = self.library.handle_library_tap(event.x, event.y)
|
||||
|
||||
if book_path:
|
||||
logger.info(f"Book selected: {book_path}")
|
||||
await self._enter_reading_mode(book_path)
|
||||
else:
|
||||
logger.debug("Tap did not hit a book")
|
||||
|
||||
elif event.gesture == GestureType.SWIPE_LEFT:
|
||||
logger.debug("Library: swipe left (next page)")
|
||||
if self.library.next_page():
|
||||
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
|
||||
# Re-render library with new page
|
||||
self.library.create_library_table()
|
||||
self.current_image = self.library.render_library()
|
||||
else:
|
||||
logger.debug("Library: already on last page")
|
||||
|
||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||
logger.debug("Library: swipe right (previous page)")
|
||||
if self.library.previous_page():
|
||||
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
|
||||
# Re-render library with new page
|
||||
self.library.create_library_table()
|
||||
self.current_image = self.library.render_library()
|
||||
else:
|
||||
logger.debug("Library: already on first page")
|
||||
|
||||
async def _handle_reading_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in reading mode.
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
# Delegate to reader's gesture handler
|
||||
logger.info(f"[APP] Calling reader.handle_touch({event.gesture.value})")
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# response.action is already a string (ActionType enum value), not the enum itself
|
||||
logger.info(f"[APP] Reader response: action={response.action}, data={response.data}")
|
||||
|
||||
# Handle special actions
|
||||
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||
logger.info("[APP] → Returning to library")
|
||||
await self._enter_library_mode()
|
||||
|
||||
elif response.action == ActionType.PAGE_TURN:
|
||||
logger.info(f"[APP] → Page turned: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_OPENED:
|
||||
logger.info(f"[APP] → Overlay opened: {self.reader.get_overlay_state()}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_CLOSED:
|
||||
logger.info("[APP] → Overlay closed")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.SETTING_CHANGED:
|
||||
logger.info(f"[APP] → Setting changed: {response.data}")
|
||||
# Update state with new settings
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.CHAPTER_SELECTED:
|
||||
logger.info(f"[APP] → Chapter selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.BOOKMARK_SELECTED:
|
||||
logger.info(f"[APP] → Bookmark selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.NAVIGATE:
|
||||
logger.debug("Navigation action")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ZOOM:
|
||||
logger.info(f"Zoom action: {response.data}")
|
||||
# Font size changed
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ERROR:
|
||||
logger.error(f"Error: {response.data}")
|
||||
|
||||
async def _update_display(self):
|
||||
"""
|
||||
Update the display with current image.
|
||||
|
||||
This method sends the current image to the HAL for display.
|
||||
"""
|
||||
if self.current_image:
|
||||
logger.info(f"[DISPLAY] Updating display: {self.current_image.size} in {self.state.mode.value} mode")
|
||||
await self.display_hal.show_image(self.current_image)
|
||||
logger.info("[DISPLAY] Display update complete")
|
||||
else:
|
||||
logger.warning("No image to display")
|
||||
|
||||
def get_current_mode(self) -> EreaderMode:
|
||||
"""Get current application mode."""
|
||||
return self.state.mode
|
||||
|
||||
def get_overlay_state(self) -> OverlayState:
|
||||
"""Get current overlay state (only valid in reading mode)."""
|
||||
if self.reader:
|
||||
return self.reader.get_overlay_state()
|
||||
return OverlayState.NONE
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if application is running."""
|
||||
return self.running
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Managers module for dreader application.
|
||||
|
||||
This module contains business logic managers that handle specific responsibilities:
|
||||
- DocumentManager: Document loading and metadata
|
||||
- SettingsManager: Font size, spacing, and rendering settings
|
||||
- HighlightCoordinator: Highlight operations coordination
|
||||
"""
|
||||
|
||||
from .document import DocumentManager
|
||||
from .settings import SettingsManager
|
||||
from .highlight_coordinator import HighlightCoordinator
|
||||
|
||||
__all__ = ['DocumentManager', 'SettingsManager', 'HighlightCoordinator']
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Document loading and metadata management.
|
||||
|
||||
This module handles EPUB and HTML loading, extracting blocks and metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.abstract.block import Block
|
||||
|
||||
|
||||
class DocumentManager:
|
||||
"""
|
||||
Handles document loading and metadata extraction.
|
||||
|
||||
Responsibilities:
|
||||
- Load EPUB files
|
||||
- Load HTML content
|
||||
- Extract document metadata (title, author, etc.)
|
||||
- Extract content blocks for rendering
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the document manager."""
|
||||
self.document_id: Optional[str] = None
|
||||
self.title: Optional[str] = None
|
||||
self.author: Optional[str] = None
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
|
||||
def load_epub(self, epub_path: str) -> bool:
|
||||
"""
|
||||
Load an EPUB file and extract content.
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Validate path
|
||||
if not os.path.exists(epub_path):
|
||||
raise FileNotFoundError(f"EPUB file not found: {epub_path}")
|
||||
|
||||
# Load the EPUB
|
||||
book = read_epub(epub_path)
|
||||
|
||||
# Extract metadata
|
||||
self.title = book.get_title() or "Unknown Title"
|
||||
self.author = book.get_author() or "Unknown Author"
|
||||
|
||||
# Create document ID from filename
|
||||
self.document_id = Path(epub_path).stem
|
||||
|
||||
# Extract all blocks from chapters
|
||||
self.blocks = []
|
||||
for chapter in book.chapters:
|
||||
if hasattr(chapter, '_blocks'):
|
||||
self.blocks.extend(chapter._blocks)
|
||||
|
||||
if not self.blocks:
|
||||
raise ValueError("No content blocks found in EPUB")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading EPUB: {e}")
|
||||
import traceback
|
||||
print(f"Full traceback:")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def load_html(self, html_string: str, title: str = "HTML Document",
|
||||
author: str = "Unknown", document_id: str = "html_doc") -> bool:
|
||||
"""
|
||||
Load HTML content directly.
|
||||
|
||||
This is useful for rendering library screens, menus, or other HTML-based UI elements.
|
||||
|
||||
Args:
|
||||
html_string: HTML content to render
|
||||
title: Document title (for metadata)
|
||||
author: Document author (for metadata)
|
||||
document_id: Unique identifier for this HTML document
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse HTML into blocks
|
||||
blocks = parse_html_string(html_string)
|
||||
|
||||
if not blocks:
|
||||
raise ValueError("No content blocks parsed from HTML")
|
||||
|
||||
# Set metadata
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.document_id = document_id
|
||||
self.blocks = blocks
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading HTML: {e}")
|
||||
return False
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if a document is currently loaded."""
|
||||
return self.blocks is not None and len(self.blocks) > 0
|
||||
|
||||
def get_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get document metadata.
|
||||
|
||||
Returns:
|
||||
Dictionary with metadata (title, author, document_id, total_blocks)
|
||||
"""
|
||||
return {
|
||||
'title': self.title,
|
||||
'author': self.author,
|
||||
'document_id': self.document_id,
|
||||
'total_blocks': len(self.blocks) if self.blocks else 0
|
||||
}
|
||||
|
||||
def get_blocks(self) -> Optional[List[Block]]:
|
||||
"""Get the list of content blocks."""
|
||||
return self.blocks
|
||||
|
||||
def clear(self):
|
||||
"""Clear the currently loaded document."""
|
||||
self.document_id = None
|
||||
self.title = None
|
||||
self.author = None
|
||||
self.blocks = None
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Highlight operations coordination.
|
||||
|
||||
This module coordinates highlight operations with the highlight manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Tuple, Optional, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
|
||||
class HighlightCoordinator:
|
||||
"""
|
||||
Coordinates highlight operations.
|
||||
|
||||
This class provides a simplified interface for highlighting operations,
|
||||
coordinating between the layout manager and highlight manager.
|
||||
"""
|
||||
|
||||
def __init__(self, document_id: str, highlights_dir: str):
|
||||
"""
|
||||
Initialize the highlight coordinator.
|
||||
|
||||
Args:
|
||||
document_id: Unique document identifier
|
||||
highlights_dir: Directory to store highlights
|
||||
"""
|
||||
self.highlight_manager = HighlightManager(
|
||||
document_id=document_id,
|
||||
highlights_dir=highlights_dir
|
||||
)
|
||||
self.layout_manager: Optional['EreaderLayoutManager'] = None
|
||||
|
||||
def set_layout_manager(self, manager: 'EreaderLayoutManager'):
|
||||
"""Set the layout manager."""
|
||||
self.layout_manager = manager
|
||||
|
||||
def highlight_word(self, x: int, y: int,
|
||||
color: Tuple[int, int, int, int] = None,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None) -> Optional[str]:
|
||||
"""
|
||||
Highlight a word at the given pixel location.
|
||||
|
||||
Args:
|
||||
x: X coordinate
|
||||
y: Y coordinate
|
||||
color: RGBA color tuple (defaults to yellow)
|
||||
note: Optional annotation for this highlight
|
||||
tags: Optional categorization tags
|
||||
|
||||
Returns:
|
||||
Highlight ID if successful, None otherwise
|
||||
"""
|
||||
if not self.layout_manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Query the pixel to find the word
|
||||
page = self.layout_manager.get_current_page()
|
||||
result = page.query_point((x, y))
|
||||
if not result or not result.text:
|
||||
return None
|
||||
|
||||
# Use default color if not provided
|
||||
if color is None:
|
||||
color = HighlightColor.YELLOW.value
|
||||
|
||||
# Create highlight from query result
|
||||
highlight = create_highlight_from_query_result(
|
||||
result,
|
||||
color=color,
|
||||
note=note,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
# Add to manager
|
||||
self.highlight_manager.add_highlight(highlight)
|
||||
|
||||
return highlight.id
|
||||
except Exception as e:
|
||||
print(f"Error highlighting word: {e}")
|
||||
return None
|
||||
|
||||
def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int],
|
||||
color: Tuple[int, int, int, int] = None,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None) -> Optional[str]:
|
||||
"""
|
||||
Highlight a range of words between two points.
|
||||
|
||||
Args:
|
||||
start: Starting (x, y) coordinates
|
||||
end: Ending (x, y) coordinates
|
||||
color: RGBA color tuple (defaults to yellow)
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
|
||||
Returns:
|
||||
Highlight ID if successful, None otherwise
|
||||
"""
|
||||
if not self.layout_manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
page = self.layout_manager.get_current_page()
|
||||
selection_range = page.query_range(start, end)
|
||||
|
||||
if not selection_range.results:
|
||||
return None
|
||||
|
||||
# Use default color if not provided
|
||||
if color is None:
|
||||
color = HighlightColor.YELLOW.value
|
||||
|
||||
# Create highlight from selection range
|
||||
highlight = create_highlight_from_query_result(
|
||||
selection_range,
|
||||
color=color,
|
||||
note=note,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
# Add to manager
|
||||
self.highlight_manager.add_highlight(highlight)
|
||||
|
||||
return highlight.id
|
||||
except Exception as e:
|
||||
print(f"Error highlighting selection: {e}")
|
||||
return None
|
||||
|
||||
def remove_highlight(self, highlight_id: str) -> bool:
|
||||
"""Remove a highlight by ID."""
|
||||
return self.highlight_manager.remove_highlight(highlight_id)
|
||||
|
||||
def list_highlights(self) -> List[Highlight]:
|
||||
"""Get all highlights for the current document."""
|
||||
return self.highlight_manager.list_highlights()
|
||||
|
||||
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
"""Get highlights that appear on a specific page."""
|
||||
return self.highlight_manager.get_highlights_for_page(page_bounds)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Remove all highlights from the current document."""
|
||||
self.highlight_manager.clear_all()
|
||||
|
||||
def render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image:
|
||||
"""
|
||||
Render highlight overlays on an image using multiply blend mode.
|
||||
|
||||
Args:
|
||||
image: Base PIL Image to draw on
|
||||
highlights: List of Highlight objects to render
|
||||
|
||||
Returns:
|
||||
New PIL Image with highlights overlaid
|
||||
"""
|
||||
# Convert to RGB for processing
|
||||
original_mode = image.mode
|
||||
if image.mode == 'RGBA':
|
||||
rgb_image = image.convert('RGB')
|
||||
alpha_channel = image.split()[-1]
|
||||
else:
|
||||
rgb_image = image.convert('RGB')
|
||||
alpha_channel = None
|
||||
|
||||
# Convert to numpy array for efficient processing
|
||||
img_array = np.array(rgb_image, dtype=np.float32)
|
||||
|
||||
# Process each highlight
|
||||
for highlight in highlights:
|
||||
# Extract RGB components from highlight color (ignore alpha)
|
||||
h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2]
|
||||
|
||||
# Create highlight multiplier (normalize to 0-1 range)
|
||||
highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32)
|
||||
|
||||
for hx, hy, hw, hh in highlight.bounds:
|
||||
# Ensure bounds are within image
|
||||
hx, hy = max(0, hx), max(0, hy)
|
||||
x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh)
|
||||
|
||||
if x2 <= hx or y2 <= hy:
|
||||
continue
|
||||
|
||||
# Extract the region to highlight
|
||||
region = img_array[hy:y2, hx:x2, :]
|
||||
|
||||
# Multiply with highlight color (like a real highlighter)
|
||||
highlighted = region * highlight_color
|
||||
|
||||
# Put the highlighted region back
|
||||
img_array[hy:y2, hx:x2, :] = highlighted
|
||||
|
||||
# Convert back to uint8 and create PIL Image
|
||||
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
|
||||
result = Image.fromarray(img_array, mode='RGB')
|
||||
|
||||
# Restore alpha channel if original had one
|
||||
if alpha_channel is not None and original_mode == 'RGBA':
|
||||
result = result.convert('RGBA')
|
||||
result.putalpha(alpha_channel)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Settings and rendering configuration management.
|
||||
|
||||
This module handles font size, spacing, and other rendering settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Any, Optional
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""
|
||||
Manages font size, spacing, font family, and rendering settings.
|
||||
|
||||
Responsibilities:
|
||||
- Font scale adjustment
|
||||
- Font family selection (serif, sans-serif, monospace)
|
||||
- Line spacing control
|
||||
- Inter-block spacing control
|
||||
- Word spacing control
|
||||
- Settings persistence helpers
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the settings manager."""
|
||||
self.font_scale = 1.0
|
||||
self.font_scale_step = 0.1 # 10% change per step
|
||||
self.font_family: Optional[BundledFont] = None # None = use document default
|
||||
self.manager: Optional[EreaderLayoutManager] = None
|
||||
|
||||
def set_manager(self, manager: EreaderLayoutManager):
|
||||
"""
|
||||
Set the layout manager to control.
|
||||
|
||||
Args:
|
||||
manager: EreaderLayoutManager instance to manage settings for
|
||||
"""
|
||||
self.manager = manager
|
||||
self.font_scale = manager.font_scale
|
||||
self.font_family = manager.get_font_family()
|
||||
|
||||
def set_font_size(self, scale: float) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set the font size scale and re-render current page.
|
||||
|
||||
Args:
|
||||
scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size)
|
||||
|
||||
Returns:
|
||||
Rendered page with new font size, or None if no manager
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
self.font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x
|
||||
page = self.manager.set_font_scale(self.font_scale)
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting font size: {e}")
|
||||
return None
|
||||
|
||||
def increase_font_size(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Increase font size by one step and re-render.
|
||||
|
||||
Returns:
|
||||
Rendered page with increased font size
|
||||
"""
|
||||
new_scale = self.font_scale + self.font_scale_step
|
||||
return self.set_font_size(new_scale)
|
||||
|
||||
def decrease_font_size(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Decrease font size by one step and re-render.
|
||||
|
||||
Returns:
|
||||
Rendered page with decreased font size
|
||||
"""
|
||||
new_scale = self.font_scale - self.font_scale_step
|
||||
return self.set_font_size(new_scale)
|
||||
|
||||
def get_font_size(self) -> float:
|
||||
"""
|
||||
Get the current font size scale.
|
||||
|
||||
Returns:
|
||||
Current font scale factor
|
||||
"""
|
||||
return self.font_scale
|
||||
|
||||
def set_font_family(self, font_family: Optional[BundledFont]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set the font family and re-render current page.
|
||||
|
||||
Args:
|
||||
font_family: BundledFont enum value (SERIF, SANS, MONOSPACE) or None for document default
|
||||
|
||||
Returns:
|
||||
Rendered page with new font family, or None if no manager
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
self.font_family = font_family
|
||||
page = self.manager.set_font_family(font_family)
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting font family: {e}")
|
||||
return None
|
||||
|
||||
def get_font_family(self) -> Optional[BundledFont]:
|
||||
"""
|
||||
Get the current font family.
|
||||
|
||||
Returns:
|
||||
Current BundledFont or None if using document default
|
||||
"""
|
||||
return self.font_family
|
||||
|
||||
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set line spacing using pyWebLayout's native support.
|
||||
|
||||
Args:
|
||||
spacing: Line spacing in pixels
|
||||
|
||||
Returns:
|
||||
Rendered page with new line spacing
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Calculate delta from current spacing
|
||||
current_spacing = self.manager.page_style.line_spacing
|
||||
target_spacing = max(0, spacing)
|
||||
delta = target_spacing - current_spacing
|
||||
|
||||
# Use pyWebLayout's built-in methods to adjust spacing
|
||||
if delta > 0:
|
||||
self.manager.increase_line_spacing(abs(delta))
|
||||
elif delta < 0:
|
||||
self.manager.decrease_line_spacing(abs(delta))
|
||||
|
||||
# Get re-rendered page
|
||||
page = self.manager.get_current_page()
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting line spacing: {e}")
|
||||
return None
|
||||
|
||||
def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set inter-block spacing using pyWebLayout's native support.
|
||||
|
||||
Args:
|
||||
spacing: Inter-block spacing in pixels
|
||||
|
||||
Returns:
|
||||
Rendered page with new inter-block spacing
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Calculate delta from current spacing
|
||||
current_spacing = self.manager.page_style.inter_block_spacing
|
||||
target_spacing = max(0, spacing)
|
||||
delta = target_spacing - current_spacing
|
||||
|
||||
# Use pyWebLayout's built-in methods to adjust spacing
|
||||
if delta > 0:
|
||||
self.manager.increase_inter_block_spacing(abs(delta))
|
||||
elif delta < 0:
|
||||
self.manager.decrease_inter_block_spacing(abs(delta))
|
||||
|
||||
# Get re-rendered page
|
||||
page = self.manager.get_current_page()
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting inter-block spacing: {e}")
|
||||
return None
|
||||
|
||||
def set_word_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||
"""
|
||||
Set word spacing using pyWebLayout's native support.
|
||||
|
||||
Args:
|
||||
spacing: Word spacing in pixels
|
||||
|
||||
Returns:
|
||||
Rendered page with new word spacing
|
||||
"""
|
||||
if not self.manager:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Calculate delta from current spacing
|
||||
current_spacing = self.manager.page_style.word_spacing
|
||||
target_spacing = max(0, spacing)
|
||||
delta = target_spacing - current_spacing
|
||||
|
||||
# Use pyWebLayout's built-in methods to adjust spacing
|
||||
if delta > 0:
|
||||
self.manager.increase_word_spacing(abs(delta))
|
||||
elif delta < 0:
|
||||
self.manager.decrease_word_spacing(abs(delta))
|
||||
|
||||
# Get re-rendered page
|
||||
page = self.manager.get_current_page()
|
||||
return page.render() if page else None
|
||||
except Exception as e:
|
||||
print(f"Error setting word spacing: {e}")
|
||||
return None
|
||||
|
||||
def get_current_settings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current rendering settings.
|
||||
|
||||
Returns:
|
||||
Dictionary with all current settings
|
||||
"""
|
||||
if not self.manager:
|
||||
return {
|
||||
'font_scale': self.font_scale,
|
||||
'font_family': self.font_family.name if self.font_family else None,
|
||||
'line_spacing': 5,
|
||||
'inter_block_spacing': 15,
|
||||
'word_spacing': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'font_scale': self.font_scale,
|
||||
'font_family': self.font_family.name if self.font_family else None,
|
||||
'line_spacing': self.manager.page_style.line_spacing,
|
||||
'inter_block_spacing': self.manager.page_style.inter_block_spacing,
|
||||
'word_spacing': self.manager.page_style.word_spacing
|
||||
}
|
||||
|
||||
def apply_settings(self, settings: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Apply rendering settings from a settings dictionary.
|
||||
|
||||
This should be called after loading a book to restore user preferences.
|
||||
|
||||
Args:
|
||||
settings: Dictionary with settings (font_scale, font_family, line_spacing, etc.)
|
||||
|
||||
Returns:
|
||||
True if settings applied successfully, False otherwise
|
||||
"""
|
||||
if not self.manager:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Apply font family
|
||||
font_family_name = settings.get('font_family', None)
|
||||
if font_family_name:
|
||||
try:
|
||||
font_family = BundledFont[font_family_name]
|
||||
if font_family != self.font_family:
|
||||
self.set_font_family(font_family)
|
||||
except KeyError:
|
||||
print(f"Warning: Unknown font family '{font_family_name}', using default")
|
||||
elif font_family_name is None and self.font_family is not None:
|
||||
# Restore to document default
|
||||
self.set_font_family(None)
|
||||
|
||||
# Apply font scale
|
||||
font_scale = settings.get('font_scale', 1.0)
|
||||
if font_scale != self.font_scale:
|
||||
self.set_font_size(font_scale)
|
||||
|
||||
# Apply line spacing
|
||||
line_spacing = settings.get('line_spacing', 5)
|
||||
if line_spacing != self.manager.page_style.line_spacing:
|
||||
self.set_line_spacing(line_spacing)
|
||||
|
||||
# Apply inter-block spacing
|
||||
inter_block_spacing = settings.get('inter_block_spacing', 15)
|
||||
if inter_block_spacing != self.manager.page_style.inter_block_spacing:
|
||||
self.set_inter_block_spacing(inter_block_spacing)
|
||||
|
||||
# Apply word spacing
|
||||
word_spacing = settings.get('word_spacing', 0)
|
||||
if word_spacing != self.manager.page_style.word_spacing:
|
||||
self.set_word_spacing(word_spacing)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error applying settings: {e}")
|
||||
return False
|
||||
@@ -1,332 +0,0 @@
|
||||
"""
|
||||
Overlay management for dreader application.
|
||||
|
||||
Handles rendering and compositing of overlay screens (TOC, Settings, Bookmarks)
|
||||
on top of the base reading page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from .state import OverlayState
|
||||
from .html_generator import (
|
||||
generate_toc_overlay,
|
||||
generate_settings_overlay,
|
||||
generate_bookmarks_overlay
|
||||
)
|
||||
|
||||
|
||||
class OverlayManager:
|
||||
"""
|
||||
Manages overlay rendering and interaction.
|
||||
|
||||
Handles:
|
||||
- Generating overlay HTML
|
||||
- Rendering HTML to images using pyWebLayout
|
||||
- Compositing overlays on top of base pages
|
||||
- Tracking current overlay state
|
||||
"""
|
||||
|
||||
def __init__(self, page_size: Tuple[int, int] = (800, 1200)):
|
||||
"""
|
||||
Initialize overlay manager.
|
||||
|
||||
Args:
|
||||
page_size: Size of the page/overlay (width, height)
|
||||
"""
|
||||
self.page_size = page_size
|
||||
self.current_overlay = OverlayState.NONE
|
||||
self._cached_base_page: Optional[Image.Image] = None
|
||||
self._cached_overlay_image: Optional[Image.Image] = None
|
||||
self._overlay_reader = None # Will be EbookReader instance for rendering overlays
|
||||
self._overlay_panel_offset: Tuple[int, int] = (0, 0) # Panel position on screen
|
||||
|
||||
def render_html_to_image(self, html: str, size: Optional[Tuple[int, int]] = None) -> Image.Image:
|
||||
"""
|
||||
Render HTML content to a PIL Image using pyWebLayout.
|
||||
|
||||
This creates a temporary EbookReader instance to render the HTML,
|
||||
then extracts the rendered page as an image.
|
||||
|
||||
Args:
|
||||
html: HTML string to render
|
||||
size: Optional (width, height) for rendering size. Defaults to self.page_size
|
||||
|
||||
Returns:
|
||||
PIL Image of the rendered HTML
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .application import EbookReader
|
||||
|
||||
render_size = size if size else self.page_size
|
||||
|
||||
# Create a temporary reader for rendering this HTML
|
||||
temp_reader = EbookReader(
|
||||
page_size=render_size,
|
||||
margin=15,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the HTML content
|
||||
success = temp_reader.load_html(
|
||||
html_string=html,
|
||||
title="Overlay",
|
||||
author="",
|
||||
document_id="temp_overlay"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError("Failed to load HTML for overlay rendering")
|
||||
|
||||
# Get the rendered page
|
||||
image = temp_reader.get_current_page()
|
||||
|
||||
# Clean up
|
||||
temp_reader.close()
|
||||
|
||||
return image
|
||||
|
||||
def composite_overlay(self, base_image: Image.Image, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay panel on top of base image with darkened background.
|
||||
|
||||
Creates a popup effect by:
|
||||
1. Darkening the base image (multiply by 0.5)
|
||||
2. Placing the overlay panel (60% size) centered on top
|
||||
|
||||
Args:
|
||||
base_image: Base page image (reading page)
|
||||
overlay_panel: Rendered overlay panel (TOC, settings, etc.)
|
||||
|
||||
Returns:
|
||||
Composited PIL Image with popup overlay effect
|
||||
"""
|
||||
from PIL import ImageDraw, ImageEnhance
|
||||
import numpy as np
|
||||
|
||||
# Convert base image to RGB
|
||||
result = base_image.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')
|
||||
|
||||
# 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)
|
||||
|
||||
# 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 open_toc_overlay(self, chapters: List[Tuple[str, int]], base_page: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Open the table of contents overlay.
|
||||
|
||||
Args:
|
||||
chapters: List of (chapter_title, chapter_index) tuples
|
||||
base_page: Current reading page to show underneath
|
||||
|
||||
Returns:
|
||||
Composited image with TOC overlay on top
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .application import EbookReader
|
||||
|
||||
# Calculate panel size (60% of screen)
|
||||
panel_width = int(self.page_size[0] * 0.6)
|
||||
panel_height = int(self.page_size[1] * 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_width, panel_height))
|
||||
|
||||
# Create reader for overlay and keep it alive for querying
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
|
||||
self._overlay_reader = EbookReader(
|
||||
page_size=(panel_width, panel_height),
|
||||
margin=15,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the HTML content
|
||||
success = self._overlay_reader.load_html(
|
||||
html_string=html,
|
||||
title="Table of Contents",
|
||||
author="",
|
||||
document_id="toc_overlay"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError("Failed to load TOC overlay HTML")
|
||||
|
||||
# Get the rendered page
|
||||
overlay_panel = self._overlay_reader.get_current_page()
|
||||
|
||||
# Calculate and store panel position for coordinate translation
|
||||
panel_x = int((self.page_size[0] - panel_width) / 2)
|
||||
panel_y = int((self.page_size[1] - panel_height) / 2)
|
||||
self._overlay_panel_offset = (panel_x, panel_y)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
self.current_overlay = OverlayState.TOC
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def open_settings_overlay(self, base_page: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Open the settings overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
|
||||
Returns:
|
||||
Composited image with settings overlay on top
|
||||
"""
|
||||
# Generate settings HTML
|
||||
html = generate_settings_overlay()
|
||||
|
||||
# Render HTML to image
|
||||
overlay_image = self.render_html_to_image(html)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_image
|
||||
self.current_overlay = OverlayState.SETTINGS
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_image)
|
||||
|
||||
def open_bookmarks_overlay(self, bookmarks: List[Dict[str, Any]], base_page: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Open the bookmarks overlay.
|
||||
|
||||
Args:
|
||||
bookmarks: List of bookmark dictionaries with 'name' and 'position' keys
|
||||
base_page: Current reading page to show underneath
|
||||
|
||||
Returns:
|
||||
Composited image with bookmarks overlay on top
|
||||
"""
|
||||
# Generate bookmarks HTML
|
||||
html = generate_bookmarks_overlay(bookmarks)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_image = self.render_html_to_image(html)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_image
|
||||
self.current_overlay = OverlayState.BOOKMARKS
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_image)
|
||||
|
||||
def close_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the current overlay and return to base page.
|
||||
|
||||
Returns:
|
||||
Base page image (without overlay), or None if no overlay was open
|
||||
"""
|
||||
if self.current_overlay == OverlayState.NONE:
|
||||
return None
|
||||
|
||||
self.current_overlay = OverlayState.NONE
|
||||
base_page = self._cached_base_page
|
||||
|
||||
# Clear caches
|
||||
self._cached_base_page = None
|
||||
self._cached_overlay_image = None
|
||||
self._overlay_panel_offset = (0, 0)
|
||||
|
||||
# Close overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
self._overlay_reader = None
|
||||
|
||||
return base_page
|
||||
|
||||
def is_overlay_open(self) -> bool:
|
||||
"""Check if an overlay is currently open."""
|
||||
return self.current_overlay != OverlayState.NONE
|
||||
|
||||
def get_current_overlay_type(self) -> OverlayState:
|
||||
"""Get the type of currently open overlay."""
|
||||
return self.current_overlay
|
||||
|
||||
def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query a pixel in the current overlay to detect interactions.
|
||||
|
||||
Uses pyWebLayout's query_point() to detect which element was tapped,
|
||||
including link targets and data attributes.
|
||||
|
||||
Args:
|
||||
x, y: Pixel coordinates to query (in screen space)
|
||||
|
||||
Returns:
|
||||
Dictionary with query result data (text, link_target, is_interactive),
|
||||
or None if no overlay open or query failed
|
||||
"""
|
||||
if not self.is_overlay_open() or 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
|
||||
|
||||
# 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))
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Overlay sub-applications for dreader.
|
||||
|
||||
Each overlay is a self-contained sub-application that handles its own:
|
||||
- HTML generation
|
||||
- Rendering logic
|
||||
- Gesture handling
|
||||
- State management
|
||||
"""
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from .navigation import NavigationOverlay
|
||||
from .settings import SettingsOverlay
|
||||
from .toc import TOCOverlay
|
||||
|
||||
__all__ = [
|
||||
'OverlaySubApplication',
|
||||
'NavigationOverlay',
|
||||
'SettingsOverlay',
|
||||
'TOCOverlay',
|
||||
]
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
This provides a common interface for all overlay types (TOC, Settings, Navigation, etc.)
|
||||
Each overlay is a self-contained sub-application that handles its own rendering and gestures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class OverlaySubApplication(ABC):
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
Each overlay type extends this class and implements:
|
||||
- open(): Generate HTML, render, and return composited image
|
||||
- handle_tap(): Process tap gestures within the overlay
|
||||
- close(): Clean up and return base page
|
||||
- get_overlay_type(): Return the OverlayState enum value
|
||||
|
||||
The base class provides:
|
||||
- Common rendering infrastructure (HTML to image conversion)
|
||||
- Coordinate translation (screen to overlay panel)
|
||||
- Query pixel support (detecting interactive elements)
|
||||
- Compositing (darkened background + centered panel)
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""
|
||||
Initialize overlay sub-application.
|
||||
|
||||
Args:
|
||||
reader: Reference to parent EbookReader instance
|
||||
"""
|
||||
self.reader = reader
|
||||
self.page_size = reader.page_size
|
||||
|
||||
# Overlay rendering state
|
||||
self._overlay_reader: Optional['EbookReader'] = None
|
||||
self._cached_base_page: Optional[Image.Image] = None
|
||||
self._cached_overlay_image: Optional[Image.Image] = None
|
||||
self._overlay_panel_offset: Tuple[int, int] = (0, 0)
|
||||
self._panel_size: Tuple[int, int] = (0, 0)
|
||||
|
||||
@abstractmethod
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""
|
||||
Get the overlay type identifier.
|
||||
|
||||
Returns:
|
||||
OverlayState enum value for this overlay
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the overlay and return composited image.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
**kwargs: Overlay-specific parameters
|
||||
|
||||
Returns:
|
||||
Composited image with overlay on top of base page
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap gesture within the overlay.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse indicating what action to take
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the overlay and clean up resources.
|
||||
|
||||
Returns:
|
||||
Base page image (without overlay), or None if not open
|
||||
"""
|
||||
base_page = self._cached_base_page
|
||||
|
||||
# Clear caches
|
||||
self._cached_base_page = None
|
||||
self._cached_overlay_image = None
|
||||
self._overlay_panel_offset = (0, 0)
|
||||
self._panel_size = (0, 0)
|
||||
|
||||
# Close overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
self._overlay_reader = None
|
||||
|
||||
return base_page
|
||||
|
||||
# ===================================================================
|
||||
# Common Infrastructure Methods
|
||||
# ===================================================================
|
||||
|
||||
def render_html_to_image(self, html: str, panel_size: Tuple[int, int]) -> Image.Image:
|
||||
"""
|
||||
Render HTML to image using a temporary EbookReader.
|
||||
|
||||
Args:
|
||||
html: HTML content to render
|
||||
panel_size: Size for the overlay panel (width, height)
|
||||
|
||||
Returns:
|
||||
Rendered PIL Image of the HTML
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from ..application import EbookReader
|
||||
|
||||
# Create or reuse overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
|
||||
self._overlay_reader = EbookReader(
|
||||
page_size=panel_size,
|
||||
margin=15,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the HTML content
|
||||
success = self._overlay_reader.load_html(
|
||||
html_string=html,
|
||||
title=f"{self.get_overlay_type().name} Overlay",
|
||||
author="",
|
||||
document_id=f"{self.get_overlay_type().name.lower()}_overlay"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError(f"Failed to load {self.get_overlay_type().name} overlay HTML")
|
||||
|
||||
# Get the rendered page
|
||||
return self._overlay_reader.get_current_page()
|
||||
|
||||
def composite_overlay(self, base_page: Image.Image, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay panel on top of base page with darkened background.
|
||||
|
||||
Creates popup effect by:
|
||||
1. Darkening the base image (70% brightness for e-ink visibility)
|
||||
2. Placing the overlay panel centered on top with a border
|
||||
|
||||
Args:
|
||||
base_page: Base reading page
|
||||
overlay_panel: Rendered overlay panel
|
||||
|
||||
Returns:
|
||||
Composited PIL Image with popup effect
|
||||
"""
|
||||
from PIL import ImageDraw, ImageEnhance
|
||||
import os
|
||||
|
||||
# Convert base image to RGB
|
||||
result = base_page.convert('RGB').copy()
|
||||
|
||||
# Lighten the background slightly (70% brightness for e-ink visibility)
|
||||
enhancer = ImageEnhance.Brightness(result)
|
||||
result = enhancer.enhance(0.7)
|
||||
|
||||
# Convert overlay panel to RGB
|
||||
if overlay_panel.mode != 'RGB':
|
||||
overlay_panel = overlay_panel.convert('RGB')
|
||||
|
||||
# DEBUG: Draw bounding boxes on interactive elements if debug mode enabled
|
||||
debug_mode = os.environ.get('DREADER_DEBUG_OVERLAY', '0') == '1'
|
||||
if debug_mode:
|
||||
overlay_panel = self._draw_debug_bounding_boxes(overlay_panel.copy())
|
||||
|
||||
# Calculate centered position for the panel
|
||||
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
|
||||
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
|
||||
|
||||
# Store panel position and size for coordinate translation
|
||||
self._overlay_panel_offset = (panel_x, panel_y)
|
||||
self._panel_size = (overlay_panel.width, overlay_panel.height)
|
||||
|
||||
# Add a thick black border around the panel for e-ink clarity
|
||||
draw = ImageDraw.Draw(result)
|
||||
border_width = 3
|
||||
draw.rectangle(
|
||||
[panel_x - border_width, panel_y - border_width,
|
||||
panel_x + overlay_panel.width + border_width,
|
||||
panel_y + overlay_panel.height + border_width],
|
||||
outline=(0, 0, 0),
|
||||
width=border_width
|
||||
)
|
||||
|
||||
# Paste the panel onto the dimmed background
|
||||
result.paste(overlay_panel, (panel_x, panel_y))
|
||||
|
||||
return result
|
||||
|
||||
def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query a pixel in the overlay to detect interactive elements.
|
||||
|
||||
Uses pyWebLayout's query_point() to detect tapped elements,
|
||||
including link targets and data attributes.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates to query
|
||||
|
||||
Returns:
|
||||
Dictionary with query result (text, link_target, is_interactive),
|
||||
or None if query failed or coordinates outside overlay
|
||||
"""
|
||||
if not self._overlay_reader:
|
||||
return None
|
||||
|
||||
# Translate screen coordinates to overlay panel coordinates
|
||||
panel_x, panel_y = self._overlay_panel_offset
|
||||
overlay_x = x - panel_x
|
||||
overlay_y = y - panel_y
|
||||
|
||||
# Check if coordinates are within the overlay panel
|
||||
if overlay_x < 0 or overlay_y < 0:
|
||||
return None
|
||||
|
||||
panel_width, panel_height = self._panel_size
|
||||
if overlay_x >= panel_width or overlay_y >= panel_height:
|
||||
return None
|
||||
|
||||
# Get the current page from the overlay reader
|
||||
if not self._overlay_reader.manager:
|
||||
return None
|
||||
|
||||
current_page = self._overlay_reader.manager.get_current_page()
|
||||
if not current_page:
|
||||
return None
|
||||
|
||||
# Query the point
|
||||
result = current_page.query_point((overlay_x, overlay_y))
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[OVERLAY_BASE] query_point({overlay_x}, {overlay_y}) returned: {result}")
|
||||
if result:
|
||||
logger.info(f"[OVERLAY_BASE] text={result.text}, link_target={result.link_target}, is_interactive={result.is_interactive}")
|
||||
logger.info(f"[OVERLAY_BASE] bounds={result.bounds}, object_type={result.object_type}")
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# Extract relevant data from QueryResult
|
||||
return {
|
||||
"text": result.text,
|
||||
"link_target": result.link_target,
|
||||
"is_interactive": result.is_interactive,
|
||||
"bounds": result.bounds,
|
||||
"object_type": result.object_type
|
||||
}
|
||||
|
||||
def _calculate_panel_size(self, width_ratio: float = 0.6, height_ratio: float = 0.7) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate overlay panel size as a percentage of screen size.
|
||||
|
||||
Args:
|
||||
width_ratio: Panel width as ratio of screen width (default 60%)
|
||||
height_ratio: Panel height as ratio of screen height (default 70%)
|
||||
|
||||
Returns:
|
||||
Tuple of (panel_width, panel_height) in pixels
|
||||
"""
|
||||
panel_width = int(self.page_size[0] * width_ratio)
|
||||
panel_height = int(self.page_size[1] * height_ratio)
|
||||
return (panel_width, panel_height)
|
||||
|
||||
def _draw_debug_bounding_boxes(self, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Draw bounding boxes around all interactive elements for debugging.
|
||||
|
||||
This scans the overlay panel and draws red rectangles around all
|
||||
clickable elements to help visualize where users need to click.
|
||||
|
||||
Args:
|
||||
overlay_panel: Overlay panel image to annotate
|
||||
|
||||
Returns:
|
||||
Annotated overlay panel with bounding boxes
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not self._overlay_reader or not self._overlay_reader.manager:
|
||||
logger.warning("[DEBUG] No overlay reader available for debug visualization")
|
||||
return overlay_panel
|
||||
|
||||
page = self._overlay_reader.manager.get_current_page()
|
||||
if not page:
|
||||
logger.warning("[DEBUG] No page available for debug visualization")
|
||||
return overlay_panel
|
||||
|
||||
# Scan for all interactive elements
|
||||
panel_width, panel_height = overlay_panel.size
|
||||
link_regions = {} # link_target -> (min_x, min_y, max_x, max_y)
|
||||
|
||||
logger.info(f"[DEBUG] Scanning {panel_width}x{panel_height} overlay for interactive elements...")
|
||||
|
||||
# Scan with fine granularity to find all interactive pixels
|
||||
for y in range(0, panel_height, 2):
|
||||
for x in range(0, panel_width, 2):
|
||||
result = page.query_point((x, y))
|
||||
if result and result.link_target:
|
||||
if result.link_target not in link_regions:
|
||||
link_regions[result.link_target] = [x, y, x, y]
|
||||
else:
|
||||
# Expand bounding box
|
||||
link_regions[result.link_target][0] = min(link_regions[result.link_target][0], x)
|
||||
link_regions[result.link_target][1] = min(link_regions[result.link_target][1], y)
|
||||
link_regions[result.link_target][2] = max(link_regions[result.link_target][2], x)
|
||||
link_regions[result.link_target][3] = max(link_regions[result.link_target][3], y)
|
||||
|
||||
# Draw bounding boxes
|
||||
draw = ImageDraw.Draw(overlay_panel)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
logger.info(f"[DEBUG] Found {len(link_regions)} interactive regions")
|
||||
|
||||
for link_target, (min_x, min_y, max_x, max_y) in link_regions.items():
|
||||
# Draw red bounding box
|
||||
draw.rectangle(
|
||||
[min_x, min_y, max_x, max_y],
|
||||
outline=(255, 0, 0),
|
||||
width=2
|
||||
)
|
||||
|
||||
# Draw label
|
||||
label = link_target[:20] # Truncate if too long
|
||||
draw.text((min_x + 2, min_y - 12), label, fill=(255, 0, 0), font=font)
|
||||
|
||||
logger.info(f"[DEBUG] {link_target}: ({min_x}, {min_y}) to ({max_x}, {max_y})")
|
||||
|
||||
return overlay_panel
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Navigation overlay sub-application.
|
||||
|
||||
Provides tabbed interface for Contents (TOC) and Bookmarks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_navigation_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class NavigationOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Unified navigation overlay with Contents and Bookmarks tabs.
|
||||
|
||||
Features:
|
||||
- Tab switching between Contents and Bookmarks
|
||||
- Chapter navigation via clickable links
|
||||
- Bookmark navigation
|
||||
- Close button
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""Initialize navigation overlay."""
|
||||
super().__init__(reader)
|
||||
|
||||
# Tab state
|
||||
self._active_tab: str = "contents"
|
||||
self._cached_chapters: List[Tuple[str, int]] = []
|
||||
self._cached_bookmarks: List[Dict[str, Any]] = []
|
||||
|
||||
# Pagination state
|
||||
self._toc_page: int = 0 # Current page in TOC
|
||||
self._toc_items_per_page: int = 10 # Items per page
|
||||
self._bookmarks_page: int = 0 # Current page in bookmarks
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return NAVIGATION overlay type."""
|
||||
return OverlayState.NAVIGATION
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the navigation overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
chapters: List of (chapter_title, chapter_index) tuples
|
||||
bookmarks: List of bookmark dicts with 'name' and optional 'position'
|
||||
active_tab: Which tab to show initially ("contents" or "bookmarks")
|
||||
|
||||
Returns:
|
||||
Composited image with navigation overlay
|
||||
"""
|
||||
chapters = kwargs.get('chapters', [])
|
||||
bookmarks = kwargs.get('bookmarks', [])
|
||||
active_tab = kwargs.get('active_tab', 'contents')
|
||||
|
||||
# Store for later use (tab switching)
|
||||
self._cached_chapters = chapters
|
||||
self._cached_bookmarks = bookmarks
|
||||
self._active_tab = active_tab
|
||||
|
||||
# Reset pagination when opening
|
||||
self._toc_page = 0
|
||||
self._bookmarks_page = 0
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in chapters
|
||||
]
|
||||
|
||||
# Generate navigation HTML with tabs
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapter_data,
|
||||
bookmarks=bookmarks,
|
||||
active_tab=active_tab,
|
||||
page_size=panel_size,
|
||||
toc_page=self._toc_page,
|
||||
toc_items_per_page=self._toc_items_per_page,
|
||||
bookmarks_page=self._bookmarks_page
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within navigation overlay.
|
||||
|
||||
Detects:
|
||||
- Tab switching (tab:contents, tab:bookmarks)
|
||||
- Chapter selection (chapter:N)
|
||||
- Bookmark selection (bookmark:name)
|
||||
- Close button (action:close)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[NAV_OVERLAY] Handling tap at ({x}, {y})")
|
||||
logger.info(f"[NAV_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
|
||||
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
logger.info(f"[NAV_OVERLAY] Query result: {query_result}")
|
||||
|
||||
# If query failed (tap outside overlay panel), close it
|
||||
if query_result is None:
|
||||
logger.info(f"[NAV_OVERLAY] Tap outside overlay panel, closing")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a link
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
logger.info(f"[NAV_OVERLAY] Found interactive link: {link_target}")
|
||||
|
||||
# Parse "tab:tabname" format for tab switching
|
||||
if link_target.startswith("tab:"):
|
||||
tab_name = link_target.split(":", 1)[1]
|
||||
self._switch_tab(tab_name)
|
||||
return GestureResponse(ActionType.TAB_SWITCHED, {
|
||||
"tab": tab_name
|
||||
})
|
||||
|
||||
# Parse "chapter:N" format for chapter navigation
|
||||
elif link_target.startswith("chapter:"):
|
||||
try:
|
||||
chapter_idx = int(link_target.split(":")[1])
|
||||
|
||||
# Get chapter title for response
|
||||
chapter_title = None
|
||||
for title, idx in self._cached_chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.reader.jump_to_chapter(chapter_idx)
|
||||
|
||||
return GestureResponse(ActionType.CHAPTER_SELECTED, {
|
||||
"chapter_index": chapter_idx,
|
||||
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse "bookmark:name" format for bookmark navigation
|
||||
elif link_target.startswith("bookmark:"):
|
||||
bookmark_name = link_target.split(":", 1)[1]
|
||||
|
||||
# Load the bookmark position
|
||||
page = self.reader.load_position(bookmark_name)
|
||||
if page:
|
||||
return GestureResponse(ActionType.BOOKMARK_SELECTED, {
|
||||
"bookmark_name": bookmark_name
|
||||
})
|
||||
else:
|
||||
# Failed to load bookmark
|
||||
return GestureResponse(ActionType.ERROR, {
|
||||
"message": f"Failed to load bookmark: {bookmark_name}"
|
||||
})
|
||||
|
||||
# Parse "action:close" format for close button
|
||||
elif link_target.startswith("action:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
if action == "close":
|
||||
logger.info(f"[NAV_OVERLAY] Close button clicked")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Parse "page:direction" format for pagination
|
||||
elif link_target.startswith("page:"):
|
||||
direction = link_target.split(":", 1)[1]
|
||||
logger.info(f"[NAV_OVERLAY] Pagination button clicked: {direction}")
|
||||
self._handle_pagination(direction)
|
||||
return GestureResponse(ActionType.PAGE_CHANGED, {
|
||||
"direction": direction,
|
||||
"tab": self._active_tab
|
||||
})
|
||||
|
||||
# Tap inside overlay but not on interactive element - keep overlay open
|
||||
logger.info(f"[NAV_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def switch_tab(self, new_tab: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Switch between tabs in the navigation overlay.
|
||||
|
||||
Args:
|
||||
new_tab: Tab to switch to ("contents" or "bookmarks")
|
||||
|
||||
Returns:
|
||||
Updated image with new tab active
|
||||
"""
|
||||
return self._switch_tab(new_tab)
|
||||
|
||||
def _switch_tab(self, new_tab: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Internal tab switching implementation.
|
||||
|
||||
Args:
|
||||
new_tab: Tab to switch to
|
||||
|
||||
Returns:
|
||||
Updated composited image with new tab active
|
||||
"""
|
||||
if not self._cached_base_page:
|
||||
return None
|
||||
|
||||
self._active_tab = new_tab
|
||||
|
||||
# Regenerate overlay with new active tab
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in self._cached_chapters
|
||||
]
|
||||
|
||||
# Generate navigation HTML with new active tab
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapter_data,
|
||||
bookmarks=self._cached_bookmarks,
|
||||
active_tab=new_tab,
|
||||
page_size=panel_size,
|
||||
toc_page=self._toc_page,
|
||||
toc_items_per_page=self._toc_items_per_page,
|
||||
bookmarks_page=self._bookmarks_page
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Update cache
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(self._cached_base_page, overlay_panel)
|
||||
|
||||
def _handle_pagination(self, direction: str) -> Optional[Image.Image]:
|
||||
"""
|
||||
Handle pagination within the active tab.
|
||||
|
||||
Args:
|
||||
direction: Either "next" or "prev"
|
||||
|
||||
Returns:
|
||||
Updated composited image with new page, or None if invalid
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if self._active_tab == "contents":
|
||||
# Calculate total pages
|
||||
total_items = len(self._cached_chapters)
|
||||
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
|
||||
|
||||
# Update page number
|
||||
if direction == "next" and self._toc_page < total_pages - 1:
|
||||
self._toc_page += 1
|
||||
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
|
||||
elif direction == "prev" and self._toc_page > 0:
|
||||
self._toc_page -= 1
|
||||
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
|
||||
else:
|
||||
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._toc_page + 1}/{total_pages}")
|
||||
return None
|
||||
|
||||
elif self._active_tab == "bookmarks":
|
||||
# Calculate total pages
|
||||
total_items = len(self._cached_bookmarks)
|
||||
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
|
||||
|
||||
# Update page number
|
||||
if direction == "next" and self._bookmarks_page < total_pages - 1:
|
||||
self._bookmarks_page += 1
|
||||
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
|
||||
elif direction == "prev" and self._bookmarks_page > 0:
|
||||
self._bookmarks_page -= 1
|
||||
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
|
||||
else:
|
||||
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._bookmarks_page + 1}/{total_pages}")
|
||||
return None
|
||||
|
||||
# Regenerate the overlay with new page
|
||||
return self._switch_tab(self._active_tab)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Settings overlay sub-application.
|
||||
|
||||
Provides interactive controls for adjusting reading settings with live preview.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_settings_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class SettingsOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Settings overlay with live preview.
|
||||
|
||||
Features:
|
||||
- Font size adjustment (increase/decrease)
|
||||
- Line spacing adjustment
|
||||
- Inter-block spacing adjustment
|
||||
- Word spacing adjustment
|
||||
- Live preview of changes on base page
|
||||
- Back to library button
|
||||
"""
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return SETTINGS overlay type."""
|
||||
return OverlayState.SETTINGS
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the settings overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
font_scale: Current font scale
|
||||
line_spacing: Current line spacing in pixels
|
||||
inter_block_spacing: Current inter-block spacing in pixels
|
||||
word_spacing: Current word spacing in pixels
|
||||
font_family: Current font family name (e.g., "SERIF", "SANS", "MONOSPACE", or None)
|
||||
|
||||
Returns:
|
||||
Composited image with settings overlay
|
||||
"""
|
||||
font_scale = kwargs.get('font_scale', 1.0)
|
||||
line_spacing = kwargs.get('line_spacing', 5)
|
||||
inter_block_spacing = kwargs.get('inter_block_spacing', 15)
|
||||
word_spacing = kwargs.get('word_spacing', 0)
|
||||
font_family = kwargs.get('font_family', 'Default')
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Generate settings HTML with current values
|
||||
html = generate_settings_overlay(
|
||||
font_scale=font_scale,
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing,
|
||||
word_spacing=word_spacing,
|
||||
font_family=font_family,
|
||||
page_size=panel_size
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within settings overlay.
|
||||
|
||||
Detects:
|
||||
- Setting adjustment controls (setting:action)
|
||||
- Back to library button (action:back_to_library)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[SETTINGS_OVERLAY] Handling tap at ({x}, {y})")
|
||||
logger.info(f"[SETTINGS_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
|
||||
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
logger.info(f"[SETTINGS_OVERLAY] Query result: {query_result}")
|
||||
|
||||
# If query failed (tap outside overlay panel), close it
|
||||
if query_result is None:
|
||||
logger.info(f"[SETTINGS_OVERLAY] Tap outside overlay panel, closing")
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a settings control link
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
logger.info(f"[SETTINGS_OVERLAY] Found interactive link: {link_target}")
|
||||
|
||||
# Parse "setting:action" format
|
||||
if link_target.startswith("setting:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
logger.info(f"[SETTINGS_OVERLAY] Applying setting change: {action}")
|
||||
return self._apply_setting_change(action)
|
||||
|
||||
# Parse "action:command" format for other actions
|
||||
elif link_target.startswith("action:"):
|
||||
action = link_target.split(":", 1)[1]
|
||||
|
||||
if action == "back_to_library":
|
||||
logger.info(f"[SETTINGS_OVERLAY] Back to library clicked")
|
||||
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
|
||||
|
||||
# Tap inside overlay but not on interactive element - keep overlay open
|
||||
logger.info(f"[SETTINGS_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def refresh(self, updated_base_page: Image.Image,
|
||||
font_scale: float,
|
||||
line_spacing: int,
|
||||
inter_block_spacing: int,
|
||||
word_spacing: int = 0,
|
||||
font_family: str = "Default") -> Image.Image:
|
||||
"""
|
||||
Refresh the settings overlay with updated values and background page.
|
||||
|
||||
This is used for live preview when settings change - it updates both
|
||||
the background page (with new settings applied) and the overlay panel
|
||||
(with new values displayed).
|
||||
|
||||
Args:
|
||||
updated_base_page: Updated reading page with new settings applied
|
||||
font_scale: Updated font scale
|
||||
line_spacing: Updated line spacing
|
||||
inter_block_spacing: Updated inter-block spacing
|
||||
word_spacing: Updated word spacing
|
||||
font_family: Updated font family
|
||||
|
||||
Returns:
|
||||
Composited image with updated settings overlay
|
||||
"""
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Generate updated settings HTML
|
||||
html = generate_settings_overlay(
|
||||
font_scale=font_scale,
|
||||
line_spacing=line_spacing,
|
||||
inter_block_spacing=inter_block_spacing,
|
||||
word_spacing=word_spacing,
|
||||
font_family=font_family,
|
||||
page_size=panel_size
|
||||
)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Update caches
|
||||
self._cached_base_page = updated_base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(updated_base_page, overlay_panel)
|
||||
|
||||
def _apply_setting_change(self, action: str) -> GestureResponse:
|
||||
"""
|
||||
Apply a setting change and refresh the overlay.
|
||||
|
||||
Args:
|
||||
action: Setting action (e.g., "font_increase", "line_spacing_decrease", "font_family_serif")
|
||||
|
||||
Returns:
|
||||
GestureResponse with SETTING_CHANGED action
|
||||
"""
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
# Apply the setting change via reader
|
||||
if action == "font_increase":
|
||||
self.reader.increase_font_size()
|
||||
elif action == "font_decrease":
|
||||
self.reader.decrease_font_size()
|
||||
elif action == "font_family_default":
|
||||
self.reader.set_font_family(None)
|
||||
elif action == "font_family_serif":
|
||||
self.reader.set_font_family(BundledFont.SERIF)
|
||||
elif action == "font_family_sans":
|
||||
self.reader.set_font_family(BundledFont.SANS)
|
||||
elif action == "font_family_monospace":
|
||||
self.reader.set_font_family(BundledFont.MONOSPACE)
|
||||
elif action == "line_spacing_increase":
|
||||
new_spacing = self.reader.page_style.line_spacing + 2
|
||||
self.reader.set_line_spacing(new_spacing)
|
||||
elif action == "line_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.line_spacing - 2)
|
||||
self.reader.set_line_spacing(new_spacing)
|
||||
elif action == "block_spacing_increase":
|
||||
new_spacing = self.reader.page_style.inter_block_spacing + 3
|
||||
self.reader.set_inter_block_spacing(new_spacing)
|
||||
elif action == "block_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.inter_block_spacing - 3)
|
||||
self.reader.set_inter_block_spacing(new_spacing)
|
||||
elif action == "word_spacing_increase":
|
||||
new_spacing = self.reader.page_style.word_spacing + 2
|
||||
self.reader.set_word_spacing(new_spacing)
|
||||
elif action == "word_spacing_decrease":
|
||||
new_spacing = max(0, self.reader.page_style.word_spacing - 2)
|
||||
self.reader.set_word_spacing(new_spacing)
|
||||
|
||||
# Re-render the base page with new settings applied
|
||||
# Must get directly from manager, not get_current_page() which returns overlay
|
||||
page = self.reader.manager.get_current_page()
|
||||
updated_page = page.render()
|
||||
|
||||
# Get font family for display
|
||||
font_family = self.reader.get_font_family()
|
||||
font_family_name = font_family.name if font_family else "Default"
|
||||
|
||||
# Refresh the settings overlay with updated values and page
|
||||
self.refresh(
|
||||
updated_base_page=updated_page,
|
||||
font_scale=self.reader.base_font_scale,
|
||||
line_spacing=self.reader.page_style.line_spacing,
|
||||
inter_block_spacing=self.reader.page_style.inter_block_spacing,
|
||||
word_spacing=self.reader.page_style.word_spacing,
|
||||
font_family=font_family_name
|
||||
)
|
||||
|
||||
return GestureResponse(ActionType.SETTING_CHANGED, {
|
||||
"action": action,
|
||||
"font_scale": self.reader.base_font_scale,
|
||||
"font_family": font_family_name,
|
||||
"line_spacing": self.reader.page_style.line_spacing,
|
||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing,
|
||||
"word_spacing": self.reader.page_style.word_spacing
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Table of Contents overlay sub-application.
|
||||
|
||||
Simple TOC overlay (deprecated in favor of NavigationOverlay).
|
||||
Kept for backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from .base import OverlaySubApplication
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
from ..html_generator import generate_toc_overlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class TOCOverlay(OverlaySubApplication):
|
||||
"""
|
||||
Simple Table of Contents overlay.
|
||||
|
||||
NOTE: This is deprecated in favor of NavigationOverlay which provides
|
||||
a unified interface for both TOC and bookmarks. Kept for backward compatibility.
|
||||
|
||||
Features:
|
||||
- List of chapters with clickable links
|
||||
- Chapter navigation
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""Initialize TOC overlay."""
|
||||
super().__init__(reader)
|
||||
self._cached_chapters: List[Tuple[str, int]] = []
|
||||
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""Return TOC overlay type."""
|
||||
return OverlayState.TOC
|
||||
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the TOC overlay.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
chapters: List of (chapter_title, chapter_index) tuples
|
||||
|
||||
Returns:
|
||||
Composited image with TOC overlay
|
||||
"""
|
||||
chapters = kwargs.get('chapters', [])
|
||||
|
||||
# Store for later use
|
||||
self._cached_chapters = chapters
|
||||
|
||||
# Calculate panel size (60% width, 70% height)
|
||||
panel_size = self._calculate_panel_size(0.6, 0.7)
|
||||
|
||||
# Convert chapters to format expected by HTML generator
|
||||
chapter_data = [
|
||||
{"index": idx, "title": title}
|
||||
for title, idx in chapters
|
||||
]
|
||||
|
||||
# Generate TOC HTML with clickable links
|
||||
html = generate_toc_overlay(chapter_data, page_size=panel_size)
|
||||
|
||||
# Render HTML to image
|
||||
overlay_panel = self.render_html_to_image(html, panel_size)
|
||||
|
||||
# Cache for later use
|
||||
self._cached_base_page = base_page.copy()
|
||||
self._cached_overlay_image = overlay_panel
|
||||
|
||||
# Composite and return
|
||||
return self.composite_overlay(base_page, overlay_panel)
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap within TOC overlay.
|
||||
|
||||
Detects:
|
||||
- Chapter selection (chapter:N)
|
||||
- Tap outside overlay (closes)
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse with appropriate action
|
||||
"""
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.query_overlay_pixel(x, y)
|
||||
|
||||
# If query failed (tap outside overlay), close it
|
||||
if not query_result:
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a link (chapter)
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
|
||||
# Parse "chapter:N" format
|
||||
if link_target.startswith("chapter:"):
|
||||
try:
|
||||
chapter_idx = int(link_target.split(":")[1])
|
||||
|
||||
# Get chapter title for response
|
||||
chapter_title = None
|
||||
for title, idx in self._cached_chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.reader.jump_to_chapter(chapter_idx)
|
||||
|
||||
return GestureResponse(ActionType.CHAPTER_SELECTED, {
|
||||
"chapter_index": chapter_idx,
|
||||
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Not a chapter link, close overlay
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
@@ -27,9 +27,10 @@ class EreaderMode(Enum):
|
||||
class OverlayState(Enum):
|
||||
"""Overlay states within READING mode"""
|
||||
NONE = "none"
|
||||
TOC = "toc"
|
||||
TOC = "toc" # Deprecated: use NAVIGATION instead
|
||||
SETTINGS = "settings"
|
||||
BOOKMARKS = "bookmarks"
|
||||
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
|
||||
NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,10 +79,11 @@ class LibraryState:
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""User 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"
|
||||
|
||||
@@ -96,6 +98,7 @@ class Settings:
|
||||
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')
|
||||
)
|
||||
@@ -374,6 +377,18 @@ class StateManager:
|
||||
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
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
# EbookReader - Simple EPUB Reader Application
|
||||
|
||||
The `EbookReader` class provides a complete, user-friendly interface for building ebook reader applications with pyWebLayout. It wraps all the complex ereader infrastructure into a simple API.
|
||||
|
||||
## Features
|
||||
|
||||
- 📖 **EPUB Loading** - Load EPUB files with automatic content extraction
|
||||
- ⬅️➡️ **Page Navigation** - Forward and backward page navigation
|
||||
- 🔖 **Position Management** - Save/load reading positions (stable across font changes)
|
||||
- 📑 **Chapter Navigation** - Jump to chapters by title or index
|
||||
- 🔤 **Font Size Control** - Increase/decrease font size with live re-rendering
|
||||
- 📏 **Spacing Control** - Adjust line and block spacing
|
||||
- 📊 **Progress Tracking** - Get reading progress and position information
|
||||
- 💾 **Context Manager Support** - Automatic cleanup with `with` statement
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from pyWebLayout.layout.ereader_application import EbookReader
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1000))
|
||||
|
||||
# Load an EPUB
|
||||
reader.load_epub("mybook.epub")
|
||||
|
||||
# Get current page as PIL Image
|
||||
page_image = reader.get_current_page()
|
||||
page_image.save("current_page.png")
|
||||
|
||||
# Navigate
|
||||
reader.next_page()
|
||||
reader.previous_page()
|
||||
|
||||
# Close reader
|
||||
reader.close()
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Initialization
|
||||
|
||||
```python
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000), # Page dimensions (width, height) in pixels
|
||||
margin=40, # Page margin in pixels
|
||||
background_color=(255, 255, 255), # RGB background color
|
||||
line_spacing=5, # Line spacing in pixels
|
||||
inter_block_spacing=15, # Space between blocks in pixels
|
||||
bookmarks_dir="ereader_bookmarks", # Directory for bookmarks
|
||||
buffer_size=5 # Number of pages to cache
|
||||
)
|
||||
```
|
||||
|
||||
### Loading EPUB
|
||||
|
||||
```python
|
||||
# Load EPUB file
|
||||
success = reader.load_epub("path/to/book.epub")
|
||||
|
||||
# Check if book is loaded
|
||||
if reader.is_loaded():
|
||||
print("Book loaded successfully")
|
||||
|
||||
# Get book information
|
||||
book_info = reader.get_book_info()
|
||||
# Returns: {
|
||||
# 'title': 'Book Title',
|
||||
# 'author': 'Author Name',
|
||||
# 'document_id': 'book',
|
||||
# 'total_blocks': 5000,
|
||||
# 'total_chapters': 20,
|
||||
# 'page_size': (800, 1000),
|
||||
# 'font_scale': 1.0
|
||||
# }
|
||||
```
|
||||
|
||||
### Page Navigation
|
||||
|
||||
```python
|
||||
# Get current page as PIL Image
|
||||
page = reader.get_current_page()
|
||||
|
||||
# Navigate to next page
|
||||
page = reader.next_page() # Returns None at end of book
|
||||
|
||||
# Navigate to previous page
|
||||
page = reader.previous_page() # Returns None at beginning
|
||||
|
||||
# Save current page to file
|
||||
reader.render_to_file("page.png")
|
||||
```
|
||||
|
||||
### Position Management
|
||||
|
||||
Positions are saved based on abstract document structure (chapter/block/word indices), making them stable across font size and styling changes.
|
||||
|
||||
```python
|
||||
# Save current position
|
||||
reader.save_position("my_bookmark")
|
||||
|
||||
# Load saved position
|
||||
page = reader.load_position("my_bookmark")
|
||||
|
||||
# List all saved positions
|
||||
positions = reader.list_saved_positions()
|
||||
# Returns: ['my_bookmark', 'chapter_2', ...]
|
||||
|
||||
# Delete a position
|
||||
reader.delete_position("my_bookmark")
|
||||
|
||||
# Get detailed position info
|
||||
info = reader.get_position_info()
|
||||
# Returns: {
|
||||
# 'position': {'chapter_index': 0, 'block_index': 42, 'word_index': 15, ...},
|
||||
# 'chapter': {'title': 'Chapter 1', 'level': 'H1', ...},
|
||||
# 'progress': 0.15, # 15% through the book
|
||||
# 'font_scale': 1.0,
|
||||
# 'book_title': 'Book Title',
|
||||
# 'book_author': 'Author Name'
|
||||
# }
|
||||
|
||||
# Get reading progress (0.0 to 1.0)
|
||||
progress = reader.get_reading_progress()
|
||||
print(f"You're {progress*100:.1f}% through the book")
|
||||
```
|
||||
|
||||
### Chapter Navigation
|
||||
|
||||
```python
|
||||
# Get all chapters
|
||||
chapters = reader.get_chapters()
|
||||
# Returns: [('Chapter 1', 0), ('Chapter 2', 1), ...]
|
||||
|
||||
# Get chapters with positions
|
||||
chapter_positions = reader.get_chapter_positions()
|
||||
# Returns: [('Chapter 1', RenderingPosition(...)), ...]
|
||||
|
||||
# Jump to chapter by index
|
||||
page = reader.jump_to_chapter(1) # Jump to second chapter
|
||||
|
||||
# Jump to chapter by title
|
||||
page = reader.jump_to_chapter("Chapter 1")
|
||||
|
||||
# Get current chapter info
|
||||
chapter_info = reader.get_current_chapter_info()
|
||||
# Returns: {'title': 'Chapter 1', 'level': HeadingLevel.H1, 'block_index': 0}
|
||||
```
|
||||
|
||||
### Font Size Control
|
||||
|
||||
```python
|
||||
# Get current font size scale
|
||||
scale = reader.get_font_size() # Default: 1.0
|
||||
|
||||
# Set specific font size scale
|
||||
page = reader.set_font_size(1.5) # 150% of normal size
|
||||
|
||||
# Increase font size by 10%
|
||||
page = reader.increase_font_size()
|
||||
|
||||
# Decrease font size by 10%
|
||||
page = reader.decrease_font_size()
|
||||
```
|
||||
|
||||
### Spacing Control
|
||||
|
||||
```python
|
||||
# Set line spacing (spacing between lines within a paragraph)
|
||||
page = reader.set_line_spacing(10) # 10 pixels
|
||||
|
||||
# Set inter-block spacing (spacing between paragraphs, headings, etc.)
|
||||
page = reader.set_inter_block_spacing(20) # 20 pixels
|
||||
```
|
||||
|
||||
### Context Manager
|
||||
|
||||
The reader supports Python's context manager protocol for automatic cleanup:
|
||||
|
||||
```python
|
||||
with EbookReader(page_size=(800, 1000)) as reader:
|
||||
reader.load_epub("book.epub")
|
||||
page = reader.get_current_page()
|
||||
# ... do stuff
|
||||
# Automatically saves position and cleans up resources
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```python
|
||||
from pyWebLayout.layout.ereader_application import EbookReader
|
||||
|
||||
# Create reader with custom settings
|
||||
with EbookReader(
|
||||
page_size=(800, 1000),
|
||||
margin=50,
|
||||
line_spacing=8,
|
||||
inter_block_spacing=20
|
||||
) as reader:
|
||||
# Load EPUB
|
||||
if not reader.load_epub("my_novel.epub"):
|
||||
print("Failed to load EPUB")
|
||||
exit(1)
|
||||
|
||||
# Get book info
|
||||
info = reader.get_book_info()
|
||||
print(f"Reading: {info['title']} by {info['author']}")
|
||||
print(f"Total chapters: {info['total_chapters']}")
|
||||
|
||||
# Navigate through first few pages
|
||||
for i in range(5):
|
||||
page = reader.get_current_page()
|
||||
page.save(f"page_{i+1:03d}.png")
|
||||
reader.next_page()
|
||||
|
||||
# Save current position
|
||||
reader.save_position("page_5")
|
||||
|
||||
# Jump to a chapter
|
||||
chapters = reader.get_chapters()
|
||||
if len(chapters) > 2:
|
||||
print(f"Jumping to: {chapters[2][0]}")
|
||||
reader.jump_to_chapter(2)
|
||||
reader.render_to_file("chapter_3_start.png")
|
||||
|
||||
# Return to saved position
|
||||
reader.load_position("page_5")
|
||||
|
||||
# Adjust font size
|
||||
reader.increase_font_size()
|
||||
reader.render_to_file("page_5_larger_font.png")
|
||||
|
||||
# Get progress
|
||||
progress = reader.get_reading_progress()
|
||||
print(f"Reading progress: {progress*100:.1f}%")
|
||||
```
|
||||
|
||||
## Demo Script
|
||||
|
||||
Run the comprehensive demo to see all features in action:
|
||||
|
||||
```bash
|
||||
python examples/ereader_demo.py path/to/book.epub
|
||||
```
|
||||
|
||||
This will demonstrate:
|
||||
- Basic page navigation
|
||||
- Position save/load
|
||||
- Chapter navigation
|
||||
- Font size adjustments
|
||||
- Spacing adjustments
|
||||
- Book information retrieval
|
||||
|
||||
The demo generates multiple PNG files showing different pages and settings.
|
||||
|
||||
## Position Storage Format
|
||||
|
||||
Positions are stored as JSON files in the `bookmarks_dir` (default: `ereader_bookmarks/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"chapter_index": 0,
|
||||
"block_index": 42,
|
||||
"word_index": 15,
|
||||
"table_row": 0,
|
||||
"table_col": 0,
|
||||
"list_item_index": 0,
|
||||
"remaining_pretext": null,
|
||||
"page_y_offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
This format is tied to the abstract document structure, making positions stable across:
|
||||
- Font size changes
|
||||
- Line spacing changes
|
||||
- Inter-block spacing changes
|
||||
- Page size changes
|
||||
|
||||
## Integration Example: Simple GUI
|
||||
|
||||
Here's a minimal example of integrating with Tkinter:
|
||||
|
||||
```python
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
from PIL import ImageTk
|
||||
from pyWebLayout.layout.ereader_application import EbookReader
|
||||
|
||||
class SimpleEreaderGUI:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.reader = EbookReader(page_size=(600, 800))
|
||||
|
||||
# Create UI
|
||||
self.image_label = tk.Label(root)
|
||||
self.image_label.pack()
|
||||
|
||||
btn_frame = tk.Frame(root)
|
||||
btn_frame.pack()
|
||||
|
||||
tk.Button(btn_frame, text="Open EPUB", command=self.open_epub).pack(side=tk.LEFT)
|
||||
tk.Button(btn_frame, text="Previous", command=self.prev_page).pack(side=tk.LEFT)
|
||||
tk.Button(btn_frame, text="Next", command=self.next_page).pack(side=tk.LEFT)
|
||||
tk.Button(btn_frame, text="Font+", command=self.increase_font).pack(side=tk.LEFT)
|
||||
tk.Button(btn_frame, text="Font-", command=self.decrease_font).pack(side=tk.LEFT)
|
||||
|
||||
def open_epub(self):
|
||||
filepath = filedialog.askopenfilename(filetypes=[("EPUB files", "*.epub")])
|
||||
if filepath:
|
||||
self.reader.load_epub(filepath)
|
||||
self.display_page()
|
||||
|
||||
def display_page(self):
|
||||
page = self.reader.get_current_page()
|
||||
if page:
|
||||
photo = ImageTk.PhotoImage(page)
|
||||
self.image_label.config(image=photo)
|
||||
self.image_label.image = photo
|
||||
|
||||
def next_page(self):
|
||||
if self.reader.next_page():
|
||||
self.display_page()
|
||||
|
||||
def prev_page(self):
|
||||
if self.reader.previous_page():
|
||||
self.display_page()
|
||||
|
||||
def increase_font(self):
|
||||
self.reader.increase_font_size()
|
||||
self.display_page()
|
||||
|
||||
def decrease_font(self):
|
||||
self.reader.decrease_font_size()
|
||||
self.display_page()
|
||||
|
||||
root = tk.Tk()
|
||||
root.title("Simple Ereader")
|
||||
app = SimpleEreaderGUI(root)
|
||||
root.mainloop()
|
||||
```
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- The reader uses intelligent page caching for fast navigation
|
||||
- First page load may take ~1 second, subsequent pages are typically < 0.1 seconds
|
||||
- Background rendering attempts to pre-cache upcoming pages (you may see pickle warnings, which can be ignored)
|
||||
- Font size changes invalidate the cache and require re-rendering from the current position
|
||||
- Position save/load is nearly instantaneous
|
||||
|
||||
## Limitations
|
||||
|
||||
- Currently supports EPUB files only (no PDF, MOBI, etc.)
|
||||
- Images in EPUBs may not render in some cases
|
||||
- Tables are skipped in rendering
|
||||
- Complex HTML layouts may not render perfectly
|
||||
- No text selection or search functionality (these would need to be added separately)
|
||||
|
||||
## See Also
|
||||
|
||||
- `examples/ereader_demo.py` - Comprehensive feature demonstration
|
||||
- `pyWebLayout/layout/ereader_manager.py` - Underlying manager class
|
||||
- `pyWebLayout/layout/ereader_layout.py` - Core layout engine
|
||||
- `examples/README_EPUB_RENDERERS.md` - Lower-level EPUB rendering
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Accelerometer Calibration Script
|
||||
|
||||
This script helps calibrate the accelerometer for gravity-based page flipping.
|
||||
It displays visual instructions on the e-ink display to guide the user through
|
||||
aligning the device with the "up" direction.
|
||||
|
||||
The calibration process:
|
||||
1. Shows an arrow pointing up
|
||||
2. User rotates device until arrow aligns with desired "up" direction
|
||||
3. User confirms by tapping screen
|
||||
4. Script saves calibration offset to config file
|
||||
|
||||
Usage:
|
||||
python examples/calibrate_accelerometer.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.gesture import GestureType
|
||||
|
||||
|
||||
class AccelerometerCalibrator:
|
||||
"""Interactive accelerometer calibration tool"""
|
||||
|
||||
def __init__(self, hal: HardwareDisplayHAL, config_path: str = "accelerometer_config.json"):
|
||||
self.hal = hal
|
||||
self.config_path = Path(config_path)
|
||||
self.width = hal.width
|
||||
self.height = hal.height
|
||||
self.calibrated = False
|
||||
|
||||
# Calibration data
|
||||
self.up_vector = None # (x, y, z) when device is in "up" position
|
||||
|
||||
async def run(self):
|
||||
"""Run the calibration process"""
|
||||
print("Starting accelerometer calibration...")
|
||||
print(f"Display: {self.width}x{self.height}")
|
||||
|
||||
await self.hal.initialize()
|
||||
|
||||
try:
|
||||
# Show welcome screen
|
||||
await self.show_welcome()
|
||||
await self.wait_for_tap()
|
||||
|
||||
# Calibration loop
|
||||
await self.calibration_loop()
|
||||
|
||||
# Show completion screen
|
||||
await self.show_completion()
|
||||
await asyncio.sleep(3)
|
||||
|
||||
finally:
|
||||
await self.hal.cleanup()
|
||||
|
||||
async def show_welcome(self):
|
||||
"""Display welcome/instruction screen"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load a font, fall back to default
|
||||
try:
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
||||
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
body_font = ImageFont.load_default()
|
||||
|
||||
# Title
|
||||
title = "Accelerometer Calibration"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
draw.text(((self.width - title_width) // 2, 100), title, fill=(0, 0, 0), font=title_font)
|
||||
|
||||
# Instructions
|
||||
instructions = [
|
||||
"This will calibrate the accelerometer",
|
||||
"for gravity-based page flipping.",
|
||||
"",
|
||||
"You will:",
|
||||
"1. See an arrow on screen",
|
||||
"2. Rotate device until arrow points UP",
|
||||
"3. Tap screen to confirm",
|
||||
"",
|
||||
"Tap anywhere to begin..."
|
||||
]
|
||||
|
||||
y = 250
|
||||
for line in instructions:
|
||||
line_bbox = draw.textbbox((0, 0), line, font=body_font)
|
||||
line_width = line_bbox[2] - line_bbox[0]
|
||||
draw.text(((self.width - line_width) // 2, y), line, fill=(0, 0, 0), font=body_font)
|
||||
y += 50
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
async def calibration_loop(self):
|
||||
"""Main calibration loop - show live arrow and accelerometer reading"""
|
||||
print("\nCalibration mode:")
|
||||
print("Rotate device until arrow points UP, then tap screen.")
|
||||
|
||||
last_display_time = 0
|
||||
display_interval = 0.2 # Update display every 200ms
|
||||
|
||||
while not self.calibrated:
|
||||
# Get current acceleration
|
||||
x, y, z = await self.hal.hal.orientation.get_acceleration()
|
||||
|
||||
# Update display if enough time has passed
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_display_time >= display_interval:
|
||||
await self.show_calibration_screen(x, y, z)
|
||||
last_display_time = current_time
|
||||
|
||||
# Check for touch event
|
||||
event = await self.hal.get_touch_event()
|
||||
if event and event.gesture == GestureType.TAP:
|
||||
# Save current orientation as "up"
|
||||
self.up_vector = (x, y, z)
|
||||
self.calibrated = True
|
||||
print(f"\nCalibration saved: up_vector = ({x:.2f}, {y:.2f}, {z:.2f})")
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.05) # Poll at ~20Hz
|
||||
|
||||
async def show_calibration_screen(self, ax: float, ay: float, az: float):
|
||||
"""
|
||||
Show arrow pointing in direction of gravity
|
||||
|
||||
Args:
|
||||
ax, ay, az: Acceleration components in m/s²
|
||||
"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate gravity direction (normalized)
|
||||
magnitude = math.sqrt(ax**2 + ay**2 + az**2)
|
||||
if magnitude < 0.1: # Avoid division by zero
|
||||
magnitude = 1.0
|
||||
|
||||
gx = ax / magnitude
|
||||
gy = ay / magnitude
|
||||
gz = az / magnitude
|
||||
|
||||
# Project gravity onto screen plane (assuming z is out of screen)
|
||||
# We want to show which way is "down" on the device
|
||||
# Arrow should point opposite to gravity (toward "up")
|
||||
arrow_dx = -gx
|
||||
arrow_dy = -gy
|
||||
|
||||
# Normalize for display
|
||||
arrow_length = min(self.width, self.height) * 0.3
|
||||
arrow_magnitude = math.sqrt(arrow_dx**2 + arrow_dy**2)
|
||||
if arrow_magnitude < 0.1:
|
||||
arrow_magnitude = 1.0
|
||||
|
||||
arrow_dx = (arrow_dx / arrow_magnitude) * arrow_length
|
||||
arrow_dy = (arrow_dy / arrow_magnitude) * arrow_length
|
||||
|
||||
# Center point
|
||||
cx = self.width // 2
|
||||
cy = self.height // 2
|
||||
|
||||
# Arrow endpoint
|
||||
end_x = cx + int(arrow_dx)
|
||||
end_y = cy + int(arrow_dy)
|
||||
|
||||
# Draw large arrow
|
||||
self.draw_arrow(draw, cx, cy, end_x, end_y, width=10)
|
||||
|
||||
# Draw circle at center
|
||||
circle_radius = 30
|
||||
draw.ellipse(
|
||||
[(cx - circle_radius, cy - circle_radius),
|
||||
(cx + circle_radius, cy + circle_radius)],
|
||||
outline=(0, 0, 0),
|
||||
width=5
|
||||
)
|
||||
|
||||
# Draw text with acceleration values
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = f"X: {ax:6.2f} m/s²"
|
||||
draw.text((50, 50), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = f"Y: {ay:6.2f} m/s²"
|
||||
draw.text((50, 100), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = f"Z: {az:6.2f} m/s²"
|
||||
draw.text((50, 150), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = "Rotate device until arrow points UP"
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
draw.text(((self.width - text_width) // 2, self.height - 150),
|
||||
text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = "Then TAP screen to save"
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
draw.text(((self.width - text_width) // 2, self.height - 100),
|
||||
text, fill=(0, 0, 0), font=font)
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
def draw_arrow(self, draw: ImageDraw.Draw, x1: int, y1: int, x2: int, y2: int, width: int = 5):
|
||||
"""Draw an arrow from (x1, y1) to (x2, y2)"""
|
||||
# Main line
|
||||
draw.line([(x1, y1), (x2, y2)], fill=(0, 0, 0), width=width)
|
||||
|
||||
# Arrow head
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
length = math.sqrt(dx**2 + dy**2)
|
||||
|
||||
if length < 0.1:
|
||||
return
|
||||
|
||||
# Normalize
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Arrow head size
|
||||
head_length = 40
|
||||
head_width = 30
|
||||
|
||||
# Perpendicular vector
|
||||
px = -dy
|
||||
py = dx
|
||||
|
||||
# Arrow head points
|
||||
p1_x = x2 - dx * head_length + px * head_width
|
||||
p1_y = y2 - dy * head_length + py * head_width
|
||||
|
||||
p2_x = x2 - dx * head_length - px * head_width
|
||||
p2_y = y2 - dy * head_length - py * head_width
|
||||
|
||||
# Draw arrow head
|
||||
draw.polygon([(x2, y2), (p1_x, p1_y), (p2_x, p2_y)], fill=(0, 0, 0))
|
||||
|
||||
async def show_completion(self):
|
||||
"""Show calibration complete screen"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
||||
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
body_font = ImageFont.load_default()
|
||||
|
||||
# Title
|
||||
title = "Calibration Complete!"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
draw.text(((self.width - title_width) // 2, 200), title, fill=(0, 0, 0), font=title_font)
|
||||
|
||||
# Details
|
||||
if self.up_vector:
|
||||
x, y, z = self.up_vector
|
||||
details = [
|
||||
f"Up vector saved:",
|
||||
f"X: {x:.3f} m/s²",
|
||||
f"Y: {y:.3f} m/s²",
|
||||
f"Z: {z:.3f} m/s²",
|
||||
"",
|
||||
f"Saved to: {self.config_path}"
|
||||
]
|
||||
|
||||
y_pos = 350
|
||||
for line in details:
|
||||
line_bbox = draw.textbbox((0, 0), line, font=body_font)
|
||||
line_width = line_bbox[2] - line_bbox[0]
|
||||
draw.text(((self.width - line_width) // 2, y_pos), line, fill=(0, 0, 0), font=body_font)
|
||||
y_pos += 50
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
# Save calibration to file
|
||||
self.save_calibration()
|
||||
|
||||
def save_calibration(self):
|
||||
"""Save calibration data to JSON file"""
|
||||
if not self.up_vector:
|
||||
print("Warning: No calibration data to save")
|
||||
return
|
||||
|
||||
x, y, z = self.up_vector
|
||||
|
||||
config = {
|
||||
"up_vector": {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z
|
||||
},
|
||||
"tilt_threshold": 0.3, # Radians (~17 degrees)
|
||||
"debounce_time": 0.5, # Seconds between tilt gestures
|
||||
}
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"Calibration saved to {self.config_path}")
|
||||
|
||||
async def wait_for_tap(self):
|
||||
"""Wait for user to tap screen"""
|
||||
while True:
|
||||
event = await self.hal.get_touch_event()
|
||||
if event and event.gesture == GestureType.TAP:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
# Create HAL with accelerometer enabled
|
||||
print("Initializing hardware...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
enable_orientation=True,
|
||||
enable_rtc=False,
|
||||
enable_power_monitor=False,
|
||||
virtual_display=False # Set to True for testing without hardware
|
||||
)
|
||||
|
||||
# Create calibrator
|
||||
calibrator = AccelerometerCalibrator(hal)
|
||||
|
||||
# Run calibration
|
||||
await calibrator.run()
|
||||
|
||||
print("\nCalibration complete!")
|
||||
print("You can now use accelerometer-based page flipping.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nCalibration cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nError during calibration: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo: Accelerometer-based Page Flipping
|
||||
|
||||
This example demonstrates how to use the accelerometer for hands-free
|
||||
page turning by tilting the device forward or backward.
|
||||
|
||||
Features:
|
||||
- Tilt device forward to advance to next page
|
||||
- Tilt device backward to go to previous page
|
||||
- Touch gestures still work normally
|
||||
- Configurable tilt threshold and debounce time
|
||||
|
||||
Prerequisites:
|
||||
1. Run calibration first: python examples/calibrate_accelerometer.py
|
||||
2. This creates accelerometer_config.json with calibration data
|
||||
|
||||
Usage:
|
||||
python examples/demo_accelerometer_page_flip.py <epub_file>
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.application import EbookReader
|
||||
from dreader.gesture import GestureType
|
||||
|
||||
|
||||
class AccelerometerPageFlipDemo:
|
||||
"""Demo application with accelerometer-based page flipping"""
|
||||
|
||||
def __init__(self, epub_path: str):
|
||||
self.epub_path = epub_path
|
||||
|
||||
# Create HAL with accelerometer enabled
|
||||
print("Initializing hardware HAL...")
|
||||
self.hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
enable_orientation=True,
|
||||
enable_rtc=False,
|
||||
enable_power_monitor=False,
|
||||
virtual_display=False # Set to True for testing without hardware
|
||||
)
|
||||
|
||||
# Create reader
|
||||
print("Creating ebook reader...")
|
||||
self.reader = EbookReader(
|
||||
page_size=(self.hal.width, self.hal.height),
|
||||
margin=60
|
||||
)
|
||||
|
||||
self.running = False
|
||||
|
||||
async def run(self):
|
||||
"""Run the demo application"""
|
||||
print("\n" + "="*60)
|
||||
print("Accelerometer Page Flip Demo")
|
||||
print("="*60)
|
||||
|
||||
# Initialize HAL
|
||||
await self.hal.initialize()
|
||||
|
||||
# Load accelerometer calibration
|
||||
print("\nLoading accelerometer calibration...")
|
||||
calibrated = self.hal.load_accelerometer_calibration("accelerometer_config.json")
|
||||
|
||||
if not calibrated:
|
||||
print("\nWARNING: Accelerometer not calibrated!")
|
||||
print("Please run: python examples/calibrate_accelerometer.py")
|
||||
print("\nProceeding with touch gestures only...\n")
|
||||
else:
|
||||
print("Accelerometer calibration loaded successfully!")
|
||||
print(f" Up vector: {self.hal.accel_up_vector}")
|
||||
print(f" Tilt threshold: {self.hal.accel_tilt_threshold:.2f} rad")
|
||||
print(f" Debounce time: {self.hal.accel_debounce_time:.2f}s")
|
||||
|
||||
# Load EPUB
|
||||
print(f"\nLoading EPUB: {self.epub_path}")
|
||||
success = self.reader.load_epub(self.epub_path)
|
||||
|
||||
if not success:
|
||||
print(f"ERROR: Failed to load {self.epub_path}")
|
||||
await self.hal.cleanup()
|
||||
return
|
||||
|
||||
print(f"Loaded: {self.reader.book_title}")
|
||||
print(f"Author: {self.reader.book_author}")
|
||||
|
||||
# Display first page
|
||||
print("\nDisplaying first page...")
|
||||
img = self.reader.get_current_page()
|
||||
await self.hal.show_image(img)
|
||||
|
||||
# Instructions
|
||||
print("\n" + "="*60)
|
||||
print("Controls:")
|
||||
print(" - Tilt FORWARD to go to next page")
|
||||
print(" - Tilt BACKWARD to go to previous page")
|
||||
print(" - Swipe LEFT for next page (touch)")
|
||||
print(" - Swipe RIGHT for previous page (touch)")
|
||||
print(" - Long press to exit")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# Main event loop
|
||||
self.running = True
|
||||
try:
|
||||
await self.event_loop()
|
||||
finally:
|
||||
await self.hal.cleanup()
|
||||
print("\nDemo finished!")
|
||||
|
||||
async def event_loop(self):
|
||||
"""Main event loop - poll for touch and accelerometer events"""
|
||||
accel_poll_interval = 0.05 # Check accelerometer every 50ms
|
||||
|
||||
while self.running:
|
||||
# Check for touch events
|
||||
touch_event = await self.hal.get_touch_event()
|
||||
if touch_event:
|
||||
await self.handle_event(touch_event)
|
||||
|
||||
# Check for accelerometer tilt events (if calibrated)
|
||||
if hasattr(self.hal, 'accel_up_vector'):
|
||||
tilt_event = await self.hal.get_tilt_gesture()
|
||||
if tilt_event:
|
||||
await self.handle_event(tilt_event)
|
||||
|
||||
# Small delay to avoid busy-waiting
|
||||
await asyncio.sleep(accel_poll_interval)
|
||||
|
||||
async def handle_event(self, event):
|
||||
"""Handle a gesture event (touch or accelerometer)"""
|
||||
gesture = event.gesture
|
||||
print(f"Gesture: {gesture.value}")
|
||||
|
||||
# Navigation gestures
|
||||
if gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]:
|
||||
await self.next_page()
|
||||
|
||||
elif gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]:
|
||||
await self.previous_page()
|
||||
|
||||
# Exit on long press
|
||||
elif gesture == GestureType.LONG_PRESS:
|
||||
print("\nLong press detected - exiting...")
|
||||
self.running = False
|
||||
|
||||
# Word tap
|
||||
elif gesture == GestureType.TAP:
|
||||
# You could implement word selection here
|
||||
print(f" Tap at ({event.x}, {event.y})")
|
||||
|
||||
async def next_page(self):
|
||||
"""Go to next page"""
|
||||
img = self.reader.next_page()
|
||||
if img:
|
||||
progress = self.reader.get_reading_progress()
|
||||
chapter = self.reader.get_current_chapter_info()
|
||||
print(f" -> Next page ({progress['percent']:.1f}% - {chapter['title']})")
|
||||
await self.hal.show_image(img)
|
||||
else:
|
||||
print(" -> At end of book")
|
||||
|
||||
async def previous_page(self):
|
||||
"""Go to previous page"""
|
||||
img = self.reader.previous_page()
|
||||
if img:
|
||||
progress = self.reader.get_reading_progress()
|
||||
chapter = self.reader.get_current_chapter_info()
|
||||
print(f" -> Previous page ({progress['percent']:.1f}% - {chapter['title']})")
|
||||
await self.hal.show_image(img)
|
||||
else:
|
||||
print(" -> At start of book")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python demo_accelerometer_page_flip.py <epub_file>")
|
||||
print("\nExample:")
|
||||
print(" python demo_accelerometer_page_flip.py ~/Books/mybook.epub")
|
||||
sys.exit(1)
|
||||
|
||||
epub_path = sys.argv[1]
|
||||
|
||||
# Check if file exists
|
||||
if not Path(epub_path).exists():
|
||||
print(f"ERROR: File not found: {epub_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Run demo
|
||||
demo = AccelerometerPageFlipDemo(epub_path)
|
||||
await demo.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nDemo interrupted by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Accelerometer Demo - Using Unified Event API
|
||||
|
||||
This is a simplified version of the accelerometer demo that uses
|
||||
the HAL's get_event() convenience method to poll both touch and
|
||||
accelerometer in a single call.
|
||||
|
||||
Usage:
|
||||
python examples/demo_accelerometer_simple.py <epub_file>
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.application import EbookReader
|
||||
from dreader.gesture import GestureType
|
||||
|
||||
|
||||
async def main():
|
||||
"""Simple demo using unified event API"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python demo_accelerometer_simple.py <epub_file>")
|
||||
sys.exit(1)
|
||||
|
||||
epub_path = sys.argv[1]
|
||||
|
||||
# Create HAL with accelerometer enabled
|
||||
print("Initializing hardware...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
enable_orientation=True
|
||||
)
|
||||
|
||||
await hal.initialize()
|
||||
|
||||
# Load accelerometer calibration (optional)
|
||||
if hal.load_accelerometer_calibration("accelerometer_config.json"):
|
||||
print("✓ Accelerometer calibrated - tilt gestures enabled")
|
||||
else:
|
||||
print("✗ No accelerometer calibration - touch only")
|
||||
|
||||
# Create reader and load book
|
||||
print(f"\nLoading: {epub_path}")
|
||||
reader = EbookReader(page_size=(hal.width, hal.height), margin=60)
|
||||
|
||||
if not reader.load_epub(epub_path):
|
||||
print(f"ERROR: Failed to load {epub_path}")
|
||||
await hal.cleanup()
|
||||
return
|
||||
|
||||
print(f"Loaded: {reader.book_title}")
|
||||
|
||||
# Display first page
|
||||
img = reader.get_current_page()
|
||||
await hal.show_image(img)
|
||||
|
||||
print("\nControls:")
|
||||
print(" Swipe LEFT or Tilt FORWARD → Next page")
|
||||
print(" Swipe RIGHT or Tilt BACKWARD → Previous page")
|
||||
print(" Long press → Exit\n")
|
||||
|
||||
# Main event loop - simple unified API!
|
||||
running = True
|
||||
while running:
|
||||
# Get event from any source (touch or accelerometer)
|
||||
event = await hal.get_event()
|
||||
|
||||
if event:
|
||||
print(f"Gesture: {event.gesture.value}")
|
||||
|
||||
# Page navigation
|
||||
if event.gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]:
|
||||
img = reader.next_page()
|
||||
if img:
|
||||
progress = reader.get_reading_progress()
|
||||
print(f" → Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)")
|
||||
await hal.show_image(img)
|
||||
else:
|
||||
print(" → End of book")
|
||||
|
||||
elif event.gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]:
|
||||
img = reader.previous_page()
|
||||
if img:
|
||||
progress = reader.get_reading_progress()
|
||||
print(f" ← Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)")
|
||||
await hal.show_image(img)
|
||||
else:
|
||||
print(" ← Start of book")
|
||||
|
||||
# Exit
|
||||
elif event.gesture == GestureType.LONG_PRESS:
|
||||
print("\nExiting...")
|
||||
running = False
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await hal.cleanup()
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo script showing TOC overlay pagination functionality.
|
||||
|
||||
This demonstrates:
|
||||
1. Opening a navigation overlay with many chapters
|
||||
2. Navigating through pages using Next/Previous buttons
|
||||
3. Switching between Contents and Bookmarks tabs with pagination
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from dreader import EbookReader, TouchEvent, GestureType
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("TOC Pagination Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Create a mock book with many chapters for demonstration
|
||||
from dreader.html_generator import generate_navigation_overlay
|
||||
|
||||
# Generate test data: 35 chapters and 20 bookmarks
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}: The Adventure Continues"} for i in range(35)]
|
||||
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i*10}"} for i in range(20)]
|
||||
|
||||
print("\nTest Data:")
|
||||
print(f" - {len(chapters)} chapters")
|
||||
print(f" - {len(bookmarks)} bookmarks")
|
||||
print(f" - Items per page: 10")
|
||||
print()
|
||||
|
||||
# Demonstrate pagination on Contents tab
|
||||
print("Contents Tab Pagination:")
|
||||
print("-" * 60)
|
||||
|
||||
# Page 1 of TOC (chapters 1-10)
|
||||
print("\n[Page 1/4] Chapters 1-10:")
|
||||
html_page1 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="contents",
|
||||
page_size=(800, 1200),
|
||||
toc_page=0,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
# Extract chapter titles for display
|
||||
for i in range(10):
|
||||
print(f" {i+1}. {chapters[i]['title']}")
|
||||
print(" [← Prev] Page 1 of 4 [Next →]")
|
||||
|
||||
# Page 2 of TOC (chapters 11-20)
|
||||
print("\n[Page 2/4] Chapters 11-20:")
|
||||
html_page2 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="contents",
|
||||
page_size=(800, 1200),
|
||||
toc_page=1,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
for i in range(10, 20):
|
||||
print(f" {i+1}. {chapters[i]['title']}")
|
||||
print(" [← Prev] Page 2 of 4 [Next →]")
|
||||
|
||||
# Page 3 of TOC (chapters 21-30)
|
||||
print("\n[Page 3/4] Chapters 21-30:")
|
||||
html_page3 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="contents",
|
||||
page_size=(800, 1200),
|
||||
toc_page=2,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
for i in range(20, 30):
|
||||
print(f" {i+1}. {chapters[i]['title']}")
|
||||
print(" [← Prev] Page 3 of 4 [Next →]")
|
||||
|
||||
# Page 4 of TOC (chapters 31-35)
|
||||
print("\n[Page 4/4] Chapters 31-35:")
|
||||
html_page4 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="contents",
|
||||
page_size=(800, 1200),
|
||||
toc_page=3,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
for i in range(30, 35):
|
||||
print(f" {i+1}. {chapters[i]['title']}")
|
||||
print(" [← Prev] Page 4 of 4 [Next →]")
|
||||
|
||||
# Demonstrate pagination on Bookmarks tab
|
||||
print("\n" + "=" * 60)
|
||||
print("Bookmarks Tab Pagination:")
|
||||
print("-" * 60)
|
||||
|
||||
# Page 1 of Bookmarks (1-10)
|
||||
print("\n[Page 1/2] Bookmarks 1-10:")
|
||||
html_bm1 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="bookmarks",
|
||||
page_size=(800, 1200),
|
||||
toc_page=0,
|
||||
bookmarks_page=0,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
for i in range(10):
|
||||
print(f" {bookmarks[i]['name']} - {bookmarks[i]['position']}")
|
||||
print(" [← Prev] Page 1 of 2 [Next →]")
|
||||
|
||||
# Page 2 of Bookmarks (11-20)
|
||||
print("\n[Page 2/2] Bookmarks 11-20:")
|
||||
html_bm2 = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="bookmarks",
|
||||
page_size=(800, 1200),
|
||||
toc_page=0,
|
||||
bookmarks_page=1,
|
||||
toc_items_per_page=10
|
||||
)
|
||||
for i in range(10, 20):
|
||||
print(f" {bookmarks[i]['name']} - {bookmarks[i]['position']}")
|
||||
print(" [← Prev] Page 2 of 2 [Next →]")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Pagination Controls:")
|
||||
print("-" * 60)
|
||||
print(" - Click 'Next →' to go to next page")
|
||||
print(" - Click '← Prev' to go to previous page")
|
||||
print(" - Page indicator shows: 'Page X of Y'")
|
||||
print(" - Buttons are disabled at boundaries:")
|
||||
print(" • '← Prev' disabled on page 1")
|
||||
print(" • 'Next →' disabled on last page")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("Interactive Gesture Flow:")
|
||||
print("-" * 60)
|
||||
print("1. User swipes up → Opens navigation overlay (page 1)")
|
||||
print("2. User taps 'Next →' → Shows page 2")
|
||||
print("3. User taps 'Next →' → Shows page 3")
|
||||
print("4. User taps chapter → Navigates to chapter & closes overlay")
|
||||
print("5. OR taps '← Prev' → Goes back to page 2")
|
||||
print()
|
||||
|
||||
print("HTML Features Implemented:")
|
||||
print("-" * 60)
|
||||
print("✓ Pagination links: <a href='page:next'> and <a href='page:prev'>")
|
||||
print("✓ Page indicator: 'Page X of Y' text")
|
||||
print("✓ Disabled styling: opacity 0.3 + pointer-events: none")
|
||||
print("✓ Separate pagination for Contents and Bookmarks tabs")
|
||||
print("✓ Automatic page calculation based on total items")
|
||||
print("✓ Graceful handling of empty lists")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo script for Settings overlay feature.
|
||||
|
||||
This script demonstrates the complete settings overlay workflow:
|
||||
1. Display reading page
|
||||
2. Swipe down from top to open settings overlay
|
||||
3. Display settings overlay with controls
|
||||
4. Tap on font size increase button
|
||||
5. Show live preview update (background page changes)
|
||||
6. Tap on line spacing increase button
|
||||
7. Show another live preview update
|
||||
8. Close overlay and show final page with new settings
|
||||
|
||||
Generates a GIF showing all these interactions.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from dreader import EbookReader, TouchEvent, GestureType
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def add_gesture_annotation(image: Image.Image, text: str, position: str = "top") -> Image.Image:
|
||||
"""
|
||||
Add a text annotation to an image showing what gesture is being performed.
|
||||
|
||||
Args:
|
||||
image: Base image
|
||||
text: Annotation text
|
||||
position: "top" or "bottom"
|
||||
|
||||
Returns:
|
||||
Image with annotation
|
||||
"""
|
||||
# Create a copy
|
||||
annotated = image.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
|
||||
# Try to use a nice font, fall back to default
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text position
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
x = (image.width - text_width) // 2
|
||||
if position == "top":
|
||||
y = 20
|
||||
else:
|
||||
y = image.height - text_height - 20
|
||||
|
||||
# Draw background rectangle
|
||||
padding = 10
|
||||
draw.rectangle(
|
||||
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
|
||||
fill=(0, 0, 0, 200)
|
||||
)
|
||||
|
||||
# Draw text
|
||||
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|
||||
|
||||
return annotated
|
||||
|
||||
|
||||
def add_swipe_arrow(image: Image.Image, start_y: int, end_y: int) -> Image.Image:
|
||||
"""
|
||||
Add a visual swipe arrow to show gesture direction.
|
||||
|
||||
Args:
|
||||
image: Base image
|
||||
start_y: Starting Y position
|
||||
end_y: Ending Y position
|
||||
|
||||
Returns:
|
||||
Image with arrow overlay
|
||||
"""
|
||||
annotated = image.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
|
||||
# Draw arrow in center of screen
|
||||
x = image.width // 2
|
||||
|
||||
# Draw line
|
||||
draw.line([(x, start_y), (x, end_y)], fill=(255, 100, 100), width=5)
|
||||
|
||||
# Draw arrowhead
|
||||
arrow_size = 20
|
||||
if end_y < start_y: # Upward arrow
|
||||
draw.polygon([
|
||||
(x, end_y),
|
||||
(x - arrow_size, end_y + arrow_size),
|
||||
(x + arrow_size, end_y + arrow_size)
|
||||
], fill=(255, 100, 100))
|
||||
else: # Downward arrow
|
||||
draw.polygon([
|
||||
(x, end_y),
|
||||
(x - arrow_size, end_y - arrow_size),
|
||||
(x + arrow_size, end_y - arrow_size)
|
||||
], fill=(255, 100, 100))
|
||||
|
||||
return annotated
|
||||
|
||||
|
||||
def add_tap_indicator(image: Image.Image, x: int, y: int, label: str = "") -> Image.Image:
|
||||
"""
|
||||
Add a visual tap indicator to show where user tapped.
|
||||
|
||||
Args:
|
||||
image: Base image
|
||||
x, y: Tap coordinates
|
||||
label: Optional label for the tap
|
||||
|
||||
Returns:
|
||||
Image with tap indicator
|
||||
"""
|
||||
annotated = image.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
|
||||
# Draw circle at tap location
|
||||
radius = 30
|
||||
draw.ellipse(
|
||||
[x - radius, y - radius, x + radius, y + radius],
|
||||
outline=(255, 100, 100),
|
||||
width=5
|
||||
)
|
||||
|
||||
# Draw crosshair
|
||||
draw.line([(x - radius - 10, y), (x + radius + 10, y)], fill=(255, 100, 100), width=3)
|
||||
draw.line([(x, y - radius - 10), (x, y + radius + 10)], fill=(255, 100, 100), width=3)
|
||||
|
||||
# Add label if provided
|
||||
if label:
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
# Position label above tap point
|
||||
label_x = x - text_width // 2
|
||||
label_y = y - radius - 40
|
||||
|
||||
draw.text((label_x, label_y), label, fill=(255, 100, 100), font=font)
|
||||
|
||||
return annotated
|
||||
|
||||
|
||||
def main():
|
||||
"""Generate Settings overlay demo GIF"""
|
||||
print("=== Settings Overlay Demo ===")
|
||||
print()
|
||||
|
||||
# Use Alice in Wonderland test book (has actual content)
|
||||
epub_path = Path(__file__).parent.parent / 'tests' / 'data' / 'test.epub'
|
||||
|
||||
if not epub_path.exists():
|
||||
print("Error: test.epub not found!")
|
||||
print(f"Looked in: {epub_path}")
|
||||
return
|
||||
|
||||
print(f"Using book: {epub_path.name}")
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load book
|
||||
print("Loading book...")
|
||||
success = reader.load_epub(str(epub_path))
|
||||
|
||||
if not success:
|
||||
print("Error: Failed to load EPUB!")
|
||||
return
|
||||
|
||||
print(f"Loaded: {reader.book_title} by {reader.book_author}")
|
||||
print()
|
||||
|
||||
# Skip to a page with actual content (past cover/title pages)
|
||||
for _ in range(3):
|
||||
reader.next_page()
|
||||
|
||||
# Prepare frames for GIF
|
||||
frames = []
|
||||
frame_duration = [] # Duration in milliseconds for each frame
|
||||
|
||||
# Frame 1: Initial reading page
|
||||
print("Frame 1: Initial reading page...")
|
||||
page1 = reader.get_current_page()
|
||||
annotated1 = add_gesture_annotation(page1, f"Reading: {reader.book_title}", "top")
|
||||
frames.append(annotated1)
|
||||
frame_duration.append(2000) # 2 seconds
|
||||
|
||||
# Frame 2: Show swipe down gesture
|
||||
print("Frame 2: Swipe down gesture...")
|
||||
swipe_visual = add_swipe_arrow(page1, 100, 300)
|
||||
annotated2 = add_gesture_annotation(swipe_visual, "Swipe down from top", "top")
|
||||
frames.append(annotated2)
|
||||
frame_duration.append(1000) # 1 second
|
||||
|
||||
# Frame 3: Settings overlay appears
|
||||
print("Frame 3: Settings overlay opens...")
|
||||
event_swipe_down = TouchEvent(gesture=GestureType.SWIPE_DOWN, x=400, y=100)
|
||||
response = reader.handle_touch(event_swipe_down)
|
||||
print(f" Response: {response.action}")
|
||||
|
||||
# Get the overlay image by calling open_settings_overlay again
|
||||
overlay_image = reader.open_settings_overlay()
|
||||
annotated3 = add_gesture_annotation(overlay_image, "Settings", "top")
|
||||
frames.append(annotated3)
|
||||
frame_duration.append(3000) # 3 seconds to read
|
||||
|
||||
# Find actual button coordinates by querying the overlay
|
||||
print("Querying overlay for button positions...")
|
||||
link_positions = {}
|
||||
if reader._active_overlay and reader._active_overlay._overlay_reader:
|
||||
page = reader._active_overlay._overlay_reader.manager.get_current_page()
|
||||
|
||||
# Scan for all links with very fine granularity to catch all buttons
|
||||
for y in range(0, 840, 3):
|
||||
for x in range(0, 480, 3):
|
||||
result = page.query_point((x, y))
|
||||
if result and result.link_target:
|
||||
if result.link_target not in link_positions:
|
||||
# Translate to screen coordinates
|
||||
panel_x_offset = int((800 - 480) / 2)
|
||||
panel_y_offset = int((1200 - 840) / 2)
|
||||
screen_x = x + panel_x_offset
|
||||
screen_y = y + panel_y_offset
|
||||
link_positions[result.link_target] = (screen_x, screen_y)
|
||||
|
||||
for link, (x, y) in sorted(link_positions.items()):
|
||||
print(f" Found: {link} at ({x}, {y})")
|
||||
|
||||
# Frame 4: Tap on font size increase button
|
||||
print("Frame 4: Tap on font size increase...")
|
||||
if 'setting:font_increase' in link_positions:
|
||||
tap_x, tap_y = link_positions['setting:font_increase']
|
||||
print(f" Using coordinates: ({tap_x}, {tap_y})")
|
||||
|
||||
tap_visual = add_tap_indicator(overlay_image, tap_x, tap_y, "Increase")
|
||||
annotated4 = add_gesture_annotation(tap_visual, "Tap to increase font size", "bottom")
|
||||
frames.append(annotated4)
|
||||
frame_duration.append(1500) # 1.5 seconds
|
||||
|
||||
# Frames 5-9: Font size increased (live preview) - show each tap individually
|
||||
print("Frames 5-9: Font size increased with live preview (5 taps, showing each)...")
|
||||
for i in range(5):
|
||||
event_tap_font = TouchEvent(gesture=GestureType.TAP, x=tap_x, y=tap_y)
|
||||
response = reader.handle_touch(event_tap_font)
|
||||
print(f" Tap {i+1}: {response.action} - Font scale: {response.data.get('font_scale', 'N/A')}")
|
||||
|
||||
# Get updated overlay image after each tap
|
||||
updated_overlay = reader.get_current_page() # This gets the composited overlay
|
||||
annotated = add_gesture_annotation(
|
||||
updated_overlay,
|
||||
f"Font: {int(reader.base_font_scale * 100)}% (tap {i+1}/5)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated)
|
||||
frame_duration.append(800) # 0.8 seconds per tap
|
||||
|
||||
# Hold on final font size for a bit longer
|
||||
final_font_overlay = reader.get_current_page()
|
||||
annotated_final = add_gesture_annotation(
|
||||
final_font_overlay,
|
||||
f"Font: {int(reader.base_font_scale * 100)}% (complete)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated_final)
|
||||
frame_duration.append(1500) # 1.5 seconds to see the final result
|
||||
else:
|
||||
print(" Skipping - button not found")
|
||||
updated_overlay = overlay_image
|
||||
|
||||
# Get current overlay state for line spacing section
|
||||
current_overlay = reader.get_current_page()
|
||||
|
||||
# Frame N: Tap on line spacing increase button
|
||||
print("Frame N: Tap on line spacing increase...")
|
||||
if 'setting:line_spacing_increase' in link_positions:
|
||||
tap_x2, tap_y2 = link_positions['setting:line_spacing_increase']
|
||||
print(f" Using coordinates: ({tap_x2}, {tap_y2})")
|
||||
|
||||
tap_visual2 = add_tap_indicator(current_overlay, tap_x2, tap_y2, "Increase")
|
||||
annotated_ls_tap = add_gesture_annotation(tap_visual2, "Tap to increase line spacing", "bottom")
|
||||
frames.append(annotated_ls_tap)
|
||||
frame_duration.append(1500) # 1.5 seconds
|
||||
|
||||
# Frames N+1 to N+5: Line spacing increased (live preview) - show each tap individually
|
||||
print("Frames N+1 to N+5: Line spacing increased with live preview (5 taps, showing each)...")
|
||||
for i in range(5):
|
||||
event_tap_spacing = TouchEvent(gesture=GestureType.TAP, x=tap_x2, y=tap_y2)
|
||||
response = reader.handle_touch(event_tap_spacing)
|
||||
print(f" Tap {i+1}: {response.action} - Line spacing: {response.data.get('line_spacing', 'N/A')}")
|
||||
|
||||
# Get updated overlay image after each tap
|
||||
updated_overlay2 = reader.get_current_page()
|
||||
annotated = add_gesture_annotation(
|
||||
updated_overlay2,
|
||||
f"Line Spacing: {reader.page_style.line_spacing}px (tap {i+1}/5)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated)
|
||||
frame_duration.append(800) # 0.8 seconds per tap
|
||||
|
||||
# Hold on final line spacing for a bit longer
|
||||
final_spacing_overlay = reader.get_current_page()
|
||||
annotated_final_ls = add_gesture_annotation(
|
||||
final_spacing_overlay,
|
||||
f"Line Spacing: {reader.page_style.line_spacing}px (complete)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated_final_ls)
|
||||
frame_duration.append(1500) # 1.5 seconds to see the final result
|
||||
else:
|
||||
print(" Skipping - button not found")
|
||||
|
||||
# Get current overlay state for paragraph spacing section
|
||||
current_overlay2 = reader.get_current_page()
|
||||
|
||||
# Frame M: Tap on paragraph spacing increase button
|
||||
print("Frame M: Tap on paragraph spacing increase...")
|
||||
if 'setting:block_spacing_increase' in link_positions:
|
||||
tap_x3, tap_y3 = link_positions['setting:block_spacing_increase']
|
||||
print(f" Using coordinates: ({tap_x3}, {tap_y3})")
|
||||
|
||||
tap_visual3 = add_tap_indicator(current_overlay2, tap_x3, tap_y3, "Increase")
|
||||
annotated_ps_tap = add_gesture_annotation(tap_visual3, "Tap to increase paragraph spacing", "bottom")
|
||||
frames.append(annotated_ps_tap)
|
||||
frame_duration.append(1500) # 1.5 seconds
|
||||
|
||||
# Frames M+1 to M+5: Paragraph spacing increased (live preview) - show each tap individually
|
||||
print("Frames M+1 to M+5: Paragraph spacing increased with live preview (5 taps, showing each)...")
|
||||
for i in range(5):
|
||||
event_tap_para = TouchEvent(gesture=GestureType.TAP, x=tap_x3, y=tap_y3)
|
||||
response = reader.handle_touch(event_tap_para)
|
||||
print(f" Tap {i+1}: {response.action} - Paragraph spacing: {response.data.get('inter_block_spacing', 'N/A')}")
|
||||
|
||||
# Get updated overlay image after each tap
|
||||
updated_overlay3 = reader.get_current_page()
|
||||
annotated = add_gesture_annotation(
|
||||
updated_overlay3,
|
||||
f"Paragraph Spacing: {reader.page_style.inter_block_spacing}px (tap {i+1}/5)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated)
|
||||
frame_duration.append(800) # 0.8 seconds per tap
|
||||
|
||||
# Hold on final paragraph spacing for a bit longer
|
||||
final_para_overlay = reader.get_current_page()
|
||||
annotated_final_ps = add_gesture_annotation(
|
||||
final_para_overlay,
|
||||
f"Paragraph Spacing: {reader.page_style.inter_block_spacing}px (complete)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated_final_ps)
|
||||
frame_duration.append(1500) # 1.5 seconds to see the final result
|
||||
else:
|
||||
print(" Skipping - button not found")
|
||||
|
||||
# Get current overlay state for word spacing section
|
||||
current_overlay3 = reader.get_current_page()
|
||||
|
||||
# Frame W: Tap on word spacing increase button
|
||||
print("Frame W: Tap on word spacing increase...")
|
||||
if 'setting:word_spacing_increase' in link_positions:
|
||||
tap_x4, tap_y4 = link_positions['setting:word_spacing_increase']
|
||||
print(f" Using coordinates: ({tap_x4}, {tap_y4})")
|
||||
|
||||
tap_visual4 = add_tap_indicator(current_overlay3, tap_x4, tap_y4, "Increase")
|
||||
annotated_ws_tap = add_gesture_annotation(tap_visual4, "Tap to increase word spacing", "bottom")
|
||||
frames.append(annotated_ws_tap)
|
||||
frame_duration.append(1500) # 1.5 seconds
|
||||
|
||||
# Frames W+1 to W+5: Word spacing increased (live preview) - show each tap individually
|
||||
print("Frames W+1 to W+5: Word spacing increased with live preview (5 taps, showing each)...")
|
||||
for i in range(5):
|
||||
event_tap_word = TouchEvent(gesture=GestureType.TAP, x=tap_x4, y=tap_y4)
|
||||
response = reader.handle_touch(event_tap_word)
|
||||
print(f" Tap {i+1}: {response.action} - Word spacing: {response.data.get('word_spacing', 'N/A')}")
|
||||
|
||||
# Get updated overlay image after each tap
|
||||
updated_overlay4 = reader.get_current_page()
|
||||
annotated = add_gesture_annotation(
|
||||
updated_overlay4,
|
||||
f"Word Spacing: {reader.page_style.word_spacing}px (tap {i+1}/5)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated)
|
||||
frame_duration.append(800) # 0.8 seconds per tap
|
||||
|
||||
# Hold on final word spacing for a bit longer
|
||||
final_word_overlay = reader.get_current_page()
|
||||
annotated_final_ws = add_gesture_annotation(
|
||||
final_word_overlay,
|
||||
f"Word Spacing: {reader.page_style.word_spacing}px (complete)",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated_final_ws)
|
||||
frame_duration.append(1500) # 1.5 seconds to see the final result
|
||||
else:
|
||||
print(" Skipping - button not found")
|
||||
|
||||
# Frame Z: Tap outside to close
|
||||
print("Frame Z: Close overlay...")
|
||||
final_overlay_state = reader.get_current_page()
|
||||
tap_visual_close = add_tap_indicator(final_overlay_state, 100, 600, "Close")
|
||||
annotated_close = add_gesture_annotation(tap_visual_close, "Tap outside to close", "bottom")
|
||||
frames.append(annotated_close)
|
||||
frame_duration.append(1500) # 1.5 seconds
|
||||
|
||||
# Final Frame: Back to reading with new settings applied
|
||||
print("Final Frame: Back to reading with new settings...")
|
||||
event_close = TouchEvent(gesture=GestureType.TAP, x=100, y=600)
|
||||
response = reader.handle_touch(event_close)
|
||||
print(f" Response: {response.action}")
|
||||
|
||||
final_page = reader.get_current_page()
|
||||
annotated_final = add_gesture_annotation(
|
||||
final_page,
|
||||
f"Settings Applied: {int(reader.base_font_scale * 100)}% font, {reader.page_style.line_spacing}px line, {reader.page_style.inter_block_spacing}px para, {reader.page_style.word_spacing}px word",
|
||||
"top"
|
||||
)
|
||||
frames.append(annotated_final)
|
||||
frame_duration.append(3000) # 3 seconds
|
||||
|
||||
# Save as GIF
|
||||
output_path = Path(__file__).parent.parent / 'docs' / 'images' / 'settings_overlay_demo.gif'
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print()
|
||||
print(f"Saving GIF with {len(frames)} frames...")
|
||||
frames[0].save(
|
||||
output_path,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=frame_duration,
|
||||
loop=0,
|
||||
optimize=False
|
||||
)
|
||||
|
||||
print(f"✓ GIF saved to: {output_path}")
|
||||
print(f" Size: {output_path.stat().st_size / 1024:.1f} KB")
|
||||
print(f" Frames: {len(frames)}")
|
||||
print(f" Total duration: {sum(frame_duration) / 1000:.1f}s")
|
||||
|
||||
# Also save individual frames for documentation
|
||||
frames_dir = output_path.parent / 'settings_overlay_frames'
|
||||
frames_dir.mkdir(exist_ok=True)
|
||||
|
||||
for i, frame in enumerate(frames):
|
||||
frame_path = frames_dir / f'frame_{i+1:02d}.png'
|
||||
frame.save(frame_path)
|
||||
|
||||
print(f"✓ Individual frames saved to: {frames_dir}")
|
||||
|
||||
# Cleanup
|
||||
reader.close()
|
||||
|
||||
print()
|
||||
print("=== Demo Complete ===")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate demo GIF showing the complete library ↔ reading workflow.
|
||||
|
||||
This script creates an animated GIF demonstrating:
|
||||
1. Library view with multiple books
|
||||
2. Selecting a book by tapping
|
||||
3. Reading the book (showing 5 pages)
|
||||
4. Closing the book (back to library)
|
||||
5. Reopening the same book
|
||||
6. Auto-resuming at the saved position
|
||||
|
||||
Usage:
|
||||
python generate_library_demo_gif.py path/to/library/directory output.gif
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.application import EbookReader
|
||||
from dreader.gesture import TouchEvent, GestureType
|
||||
|
||||
|
||||
def add_annotation(image: Image.Image, text: str, position: str = "top") -> Image.Image:
|
||||
"""
|
||||
Add annotation text to an image.
|
||||
|
||||
Args:
|
||||
image: PIL Image to annotate
|
||||
text: Annotation text
|
||||
position: "top" or "bottom"
|
||||
|
||||
Returns:
|
||||
New image with annotation
|
||||
"""
|
||||
# Create a copy
|
||||
img = image.copy()
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to use a nice font, fall back to default
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Get text size
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Calculate position
|
||||
x = (img.width - text_width) // 2
|
||||
if position == "top":
|
||||
y = 20
|
||||
else:
|
||||
y = img.height - text_height - 20
|
||||
|
||||
# Draw background rectangle
|
||||
padding = 10
|
||||
draw.rectangle(
|
||||
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
|
||||
fill=(0, 0, 0, 200)
|
||||
)
|
||||
|
||||
# Draw text
|
||||
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def add_tap_indicator(image: Image.Image, x: int, y: int, label: str = "TAP") -> Image.Image:
|
||||
"""
|
||||
Add a visual tap indicator at coordinates.
|
||||
|
||||
Args:
|
||||
image: PIL Image
|
||||
x, y: Tap coordinates
|
||||
label: Label text
|
||||
|
||||
Returns:
|
||||
New image with tap indicator
|
||||
"""
|
||||
img = image.copy()
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw circle at tap location
|
||||
radius = 30
|
||||
draw.ellipse(
|
||||
[x - radius, y - radius, x + radius, y + radius],
|
||||
outline=(255, 0, 0),
|
||||
width=4
|
||||
)
|
||||
|
||||
# Draw crosshair
|
||||
cross_size = 10
|
||||
draw.line([x - cross_size, y, x + cross_size, y], fill=(255, 0, 0), width=3)
|
||||
draw.line([x, y - cross_size, x, y + cross_size], fill=(255, 0, 0), width=3)
|
||||
|
||||
# Draw label
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Position label above tap
|
||||
label_x = x - text_width // 2
|
||||
label_y = y - radius - text_height - 10
|
||||
|
||||
# Background for label
|
||||
padding = 5
|
||||
draw.rectangle(
|
||||
[label_x - padding, label_y - padding,
|
||||
label_x + text_width + padding, label_y + text_height + padding],
|
||||
fill=(255, 0, 0)
|
||||
)
|
||||
draw.text((label_x, label_y), label, fill=(255, 255, 255), font=font)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def generate_library_demo_gif(library_path: str, output_path: str):
|
||||
"""
|
||||
Generate the demo GIF.
|
||||
|
||||
Args:
|
||||
library_path: Path to directory containing EPUB files
|
||||
output_path: Output GIF file path
|
||||
"""
|
||||
frames = []
|
||||
frame_durations = [] # Duration for each frame in milliseconds
|
||||
|
||||
print("Generating library demo GIF...")
|
||||
print("=" * 70)
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 1: Library view
|
||||
# ===================================================================
|
||||
print("\n1. Rendering library view...")
|
||||
library = LibraryManager(
|
||||
library_path=library_path,
|
||||
page_size=(800, 1200)
|
||||
)
|
||||
|
||||
books = library.scan_library()
|
||||
print(f" Found {len(books)} books")
|
||||
|
||||
if len(books) == 0:
|
||||
print("Error: No books found in library")
|
||||
sys.exit(1)
|
||||
|
||||
library_image = library.render_library()
|
||||
annotated = add_annotation(library_image, "📚 My Library - Select a book", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(2000) # Hold for 2 seconds
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 2: Show tap on first book
|
||||
# ===================================================================
|
||||
print("2. Showing book selection...")
|
||||
tap_x, tap_y = 400, 150 # Approximate position of first book
|
||||
tap_frame = add_tap_indicator(library_image, tap_x, tap_y, "SELECT BOOK")
|
||||
annotated = add_annotation(tap_frame, "📚 Tap to open book", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(1500)
|
||||
|
||||
# Get the selected book
|
||||
selected_book = library.handle_library_tap(tap_x, tap_y)
|
||||
if not selected_book:
|
||||
selected_book = books[0]['path']
|
||||
|
||||
print(f" Selected: {selected_book}")
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 3-7: Reading pages
|
||||
# ===================================================================
|
||||
print("3. Opening book and reading pages...")
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1200),
|
||||
margin=40,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
reader.load_epub(selected_book)
|
||||
book_info = reader.get_book_info()
|
||||
print(f" Title: {book_info['title']}")
|
||||
|
||||
# First page
|
||||
page = reader.get_current_page()
|
||||
annotated = add_annotation(page, f"📖 {book_info['title']} - Page 1", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(1500)
|
||||
|
||||
# Turn 4 more pages (total 5 pages)
|
||||
for i in range(2, 6):
|
||||
print(f" Reading page {i}...")
|
||||
reader.next_page()
|
||||
page = reader.get_current_page()
|
||||
annotated = add_annotation(page, f"📖 Reading - Page {i}", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(1000) # Faster page turns
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 8: Show settings overlay with "Back to Library"
|
||||
# ===================================================================
|
||||
print("4. Opening settings overlay...")
|
||||
settings_overlay = reader.open_settings_overlay()
|
||||
if settings_overlay:
|
||||
annotated = add_annotation(settings_overlay, "⚙️ Settings - Tap 'Back to Library'", "top")
|
||||
# Show where to tap (estimated position of back button)
|
||||
tap_frame = add_tap_indicator(annotated, 400, 950, "BACK")
|
||||
frames.append(tap_frame)
|
||||
frame_durations.append(2000)
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 9: Save position and return to library
|
||||
# ===================================================================
|
||||
print("5. Saving position and returning to library...")
|
||||
# Save current position for resume
|
||||
reader.save_position("__auto_resume__")
|
||||
pos_info = reader.get_position_info()
|
||||
saved_progress = pos_info['progress'] * 100
|
||||
print(f" Saved at {saved_progress:.1f}% progress")
|
||||
|
||||
# Close reader
|
||||
reader.close()
|
||||
|
||||
# Re-render library
|
||||
library_image = library.render_library()
|
||||
annotated = add_annotation(library_image, "📚 Back to Library (position saved)", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(2000)
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 10: Tap same book again
|
||||
# ===================================================================
|
||||
print("6. Re-selecting same book...")
|
||||
tap_frame = add_tap_indicator(library_image, tap_x, tap_y, "REOPEN")
|
||||
annotated = add_annotation(tap_frame, "📚 Tap to reopen book", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(1500)
|
||||
|
||||
# ===================================================================
|
||||
# FRAME 11: Reopen book and auto-resume
|
||||
# ===================================================================
|
||||
print("7. Reopening book with auto-resume...")
|
||||
reader2 = EbookReader(
|
||||
page_size=(800, 1200),
|
||||
margin=40,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
reader2.load_epub(selected_book)
|
||||
|
||||
# Load saved position
|
||||
resumed_page = reader2.load_position("__auto_resume__")
|
||||
if resumed_page:
|
||||
pos_info = reader2.get_position_info()
|
||||
progress = pos_info['progress'] * 100
|
||||
print(f" ✓ Resumed at {progress:.1f}% progress")
|
||||
|
||||
annotated = add_annotation(resumed_page, f"✅ Auto-resumed at {progress:.1f}%", "top")
|
||||
frames.append(annotated)
|
||||
frame_durations.append(3000) # Hold final frame longer
|
||||
|
||||
reader2.close()
|
||||
|
||||
# ===================================================================
|
||||
# Save GIF
|
||||
# ===================================================================
|
||||
print("\n8. Saving GIF...")
|
||||
print(f" Total frames: {len(frames)}")
|
||||
print(f" Output: {output_path}")
|
||||
|
||||
# Save as GIF with variable durations
|
||||
frames[0].save(
|
||||
output_path,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=frame_durations,
|
||||
loop=0, # Loop forever
|
||||
optimize=False # Keep quality
|
||||
)
|
||||
|
||||
print(f"\n✓ Demo GIF created: {output_path}")
|
||||
print(f" Size: {os.path.getsize(output_path) / 1024 / 1024:.1f} MB")
|
||||
|
||||
# Cleanup
|
||||
library.cleanup()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Demo complete!")
|
||||
print("\nThe GIF demonstrates:")
|
||||
print(" 1. Library view with book selection")
|
||||
print(" 2. Opening a book and reading 5 pages")
|
||||
print(" 3. Settings overlay with 'Back to Library' button")
|
||||
print(" 4. Returning to library (with position saved)")
|
||||
print(" 5. Reopening the same book")
|
||||
print(" 6. Auto-resuming at saved position")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python generate_library_demo_gif.py path/to/library [output.gif]")
|
||||
print("\nExample:")
|
||||
print(" python generate_library_demo_gif.py tests/data/library-epub/")
|
||||
print(" python generate_library_demo_gif.py tests/data/library-epub/ doc/images/custom_demo.gif")
|
||||
sys.exit(1)
|
||||
|
||||
library_path = sys.argv[1]
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "doc/images/library_reading_demo.gif"
|
||||
|
||||
if not os.path.exists(library_path):
|
||||
print(f"Error: Directory not found: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(library_path):
|
||||
print(f"Error: Not a directory: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
generate_library_demo_gif(library_path, output_path)
|
||||
except Exception as e:
|
||||
print(f"\nError generating demo: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,216 +0,0 @@
|
||||
"""
|
||||
Demonstration of HTML generation for dreader UI.
|
||||
|
||||
This example shows how to:
|
||||
1. Scan a book directory
|
||||
2. Generate library view HTML
|
||||
3. Load a book and generate reader view HTML
|
||||
4. Generate overlay HTML for settings, TOC, and bookmarks
|
||||
|
||||
The generated HTML strings can be passed to a HAL (Hardware Abstraction Layer)
|
||||
for rendering on the target device.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from dreader import create_ebook_reader
|
||||
from dreader.html_generator import (
|
||||
generate_library_html,
|
||||
generate_reader_html,
|
||||
generate_settings_overlay,
|
||||
generate_toc_overlay,
|
||||
generate_bookmarks_overlay
|
||||
)
|
||||
from dreader.book_utils import (
|
||||
scan_book_directory,
|
||||
get_chapter_list,
|
||||
get_bookmark_list,
|
||||
page_image_to_base64
|
||||
)
|
||||
|
||||
|
||||
def demo_library_view():
|
||||
"""Generate and save library view HTML."""
|
||||
print("Generating library view...")
|
||||
|
||||
# Scan books directory
|
||||
books_dir = Path('tests/data')
|
||||
books = scan_book_directory(books_dir)
|
||||
|
||||
print(f"Found {len(books)} books:")
|
||||
for book in books:
|
||||
print(f" - {book['title']} by {book['author']}")
|
||||
|
||||
# Generate HTML
|
||||
library_html = generate_library_html(books)
|
||||
|
||||
# Save to file for inspection
|
||||
output_path = Path('output/library.html')
|
||||
output_path.parent.mkdir(exist_ok=True)
|
||||
output_path.write_text(library_html)
|
||||
|
||||
print(f"Library HTML saved to: {output_path}")
|
||||
print(f"HTML length: {len(library_html)} characters\n")
|
||||
|
||||
|
||||
def demo_reader_view():
|
||||
"""Generate and save reader view HTML."""
|
||||
print("Generating reader view...")
|
||||
|
||||
# Load a test book
|
||||
book_path = Path('tests/data/test.epub')
|
||||
if not book_path.exists():
|
||||
print(f"Test book not found at {book_path}")
|
||||
return
|
||||
|
||||
reader = create_ebook_reader(page_size=(800, 1000))
|
||||
reader.load_epub(str(book_path))
|
||||
|
||||
# Get current page
|
||||
page_image = reader.get_current_page()
|
||||
page_base64 = page_image_to_base64(page_image)
|
||||
|
||||
# Generate HTML
|
||||
reader_html = generate_reader_html(
|
||||
book_title=reader.book_title or "Unknown Title",
|
||||
book_author=reader.book_author or "Unknown Author",
|
||||
page_image_data=page_base64
|
||||
)
|
||||
|
||||
# Save to file
|
||||
output_path = Path('output/reader.html')
|
||||
output_path.write_text(reader_html)
|
||||
|
||||
print(f"Reader HTML saved to: {output_path}")
|
||||
print(f"HTML length: {len(reader_html)} characters")
|
||||
print(f"Book: {reader.book_title} by {reader.book_author}\n")
|
||||
|
||||
|
||||
def demo_overlays():
|
||||
"""Generate and save overlay HTML."""
|
||||
print("Generating overlay views...")
|
||||
|
||||
# Load a test book
|
||||
book_path = Path('tests/data/test.epub')
|
||||
if not book_path.exists():
|
||||
print(f"Test book not found at {book_path}")
|
||||
return
|
||||
|
||||
reader = create_ebook_reader(page_size=(800, 1000))
|
||||
reader.load_epub(str(book_path))
|
||||
|
||||
# 1. Settings overlay
|
||||
settings_html = generate_settings_overlay()
|
||||
settings_path = Path('output/overlay_settings.html')
|
||||
settings_path.write_text(settings_html)
|
||||
print(f"Settings overlay saved to: {settings_path}")
|
||||
|
||||
# 2. TOC overlay
|
||||
chapters = get_chapter_list(reader)
|
||||
print(f"Found {len(chapters)} chapters")
|
||||
toc_html = generate_toc_overlay(chapters)
|
||||
toc_path = Path('output/overlay_toc.html')
|
||||
toc_path.write_text(toc_html)
|
||||
print(f"TOC overlay saved to: {toc_path}")
|
||||
|
||||
# 3. Bookmarks overlay (create some test bookmarks first)
|
||||
reader.save_position('Chapter 1 Start')
|
||||
reader.next_page()
|
||||
reader.next_page()
|
||||
reader.save_position('Page 3')
|
||||
|
||||
bookmarks = get_bookmark_list(reader)
|
||||
print(f"Found {len(bookmarks)} bookmarks")
|
||||
bookmarks_html = generate_bookmarks_overlay(bookmarks)
|
||||
bookmarks_path = Path('output/overlay_bookmarks.html')
|
||||
bookmarks_path.write_text(bookmarks_html)
|
||||
print(f"Bookmarks overlay saved to: {bookmarks_path}\n")
|
||||
|
||||
|
||||
def demo_hal_usage():
|
||||
"""
|
||||
Demonstrate how a HAL would use these functions.
|
||||
|
||||
This simulates how your Hardware Abstraction Layer would
|
||||
interact with the HTML generation functions.
|
||||
"""
|
||||
print("=" * 60)
|
||||
print("HAL Integration Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Initialize with books directory
|
||||
books_dir = Path('tests/data')
|
||||
|
||||
# Step 2: Show library view
|
||||
books = scan_book_directory(books_dir)
|
||||
library_html = generate_library_html(books)
|
||||
print(f"\n[HAL] Rendering library with {len(books)} books")
|
||||
print(f"[HAL] HTML size: {len(library_html)} bytes")
|
||||
|
||||
# Step 3: User selects a book (simulated)
|
||||
if books:
|
||||
selected_book = books[0]
|
||||
print(f"\n[HAL] User selected: {selected_book['title']}")
|
||||
|
||||
# Step 4: Load book and show reader view
|
||||
reader = create_ebook_reader(page_size=(800, 1000))
|
||||
reader.load_epub(selected_book['path'])
|
||||
|
||||
page_image = reader.get_current_page()
|
||||
page_base64 = page_image_to_base64(page_image)
|
||||
|
||||
reader_html = generate_reader_html(
|
||||
book_title=reader.book_title,
|
||||
book_author=reader.book_author,
|
||||
page_image_data=page_base64
|
||||
)
|
||||
print(f"[HAL] Rendering reader view")
|
||||
print(f"[HAL] HTML size: {len(reader_html)} bytes")
|
||||
|
||||
# Step 5: User presses "Settings" button (simulated)
|
||||
print(f"\n[HAL] User pressed 'Settings' button")
|
||||
settings_html = generate_settings_overlay()
|
||||
print(f"[HAL] Rendering settings overlay on top of page")
|
||||
print(f"[HAL] HTML size: {len(settings_html)} bytes")
|
||||
|
||||
# Step 6: User presses "Contents" button (simulated)
|
||||
print(f"\n[HAL] User pressed 'Contents' button")
|
||||
chapters = get_chapter_list(reader)
|
||||
toc_html = generate_toc_overlay(chapters)
|
||||
print(f"[HAL] Rendering TOC overlay with {len(chapters)} chapters")
|
||||
print(f"[HAL] HTML size: {len(toc_html)} bytes")
|
||||
|
||||
# Step 7: User navigates pages (simulated)
|
||||
print(f"\n[HAL] User pressed 'Next' button")
|
||||
reader.next_page()
|
||||
page_image = reader.get_current_page()
|
||||
page_base64 = page_image_to_base64(page_image)
|
||||
reader_html = generate_reader_html(
|
||||
book_title=reader.book_title,
|
||||
book_author=reader.book_author,
|
||||
page_image_data=page_base64
|
||||
)
|
||||
print(f"[HAL] Rendering next page")
|
||||
print(f"[HAL] HTML size: {len(reader_html)} bytes")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("dreader HTML Generation Demo")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Create output directory
|
||||
Path('output').mkdir(exist_ok=True)
|
||||
|
||||
# Run demos
|
||||
demo_library_view()
|
||||
demo_reader_view()
|
||||
demo_overlays()
|
||||
demo_hal_usage()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo complete!")
|
||||
print("=" * 60)
|
||||
print("\nGenerated HTML files have been saved to the 'output/' directory.")
|
||||
print("Open them in a browser to see how they look.")
|
||||
print("\nIn a real application, these HTML strings would be passed")
|
||||
print("to your HAL for rendering on the target display device.")
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration demo: Library → Reading → Settings → Back to Library
|
||||
|
||||
This example demonstrates the complete LIBRARY ↔ READING mode transition workflow:
|
||||
1. Display a library of EPUB files
|
||||
2. Select a book by clicking/tapping
|
||||
3. Open and read the selected book
|
||||
4. Access settings overlay
|
||||
5. Return to library from the settings overlay
|
||||
6. Select another book
|
||||
|
||||
This demonstrates the full user flow for an e-reader application.
|
||||
|
||||
Usage:
|
||||
python library_reading_integration.py path/to/library/directory
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path to import dreader
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.application import EbookReader
|
||||
from dreader.gesture import TouchEvent, GestureType, ActionType
|
||||
|
||||
|
||||
def print_separator():
|
||||
"""Print a visual separator."""
|
||||
print("\n" + "="*70 + "\n")
|
||||
|
||||
|
||||
def simulate_mode_transition_workflow(library_path: str):
|
||||
"""
|
||||
Simulate the complete workflow of library browsing and book reading.
|
||||
|
||||
Args:
|
||||
library_path: Path to directory containing EPUB files
|
||||
"""
|
||||
print_separator()
|
||||
print("INTEGRATION TEST: LIBRARY ↔ READING MODE TRANSITIONS")
|
||||
print_separator()
|
||||
|
||||
# ===================================================================
|
||||
# STEP 1: LIBRARY MODE - Display available books
|
||||
# ===================================================================
|
||||
print("STEP 1: LIBRARY MODE - Displaying available books")
|
||||
print("-" * 70)
|
||||
|
||||
# Initialize library manager
|
||||
library = LibraryManager(
|
||||
library_path=library_path,
|
||||
page_size=(800, 1200)
|
||||
)
|
||||
|
||||
# Scan for books
|
||||
books = library.scan_library()
|
||||
print(f"✓ Found {len(books)} books in library")
|
||||
|
||||
if len(books) == 0:
|
||||
print("Error: No EPUB files found in library directory")
|
||||
print(f"Please add some .epub files to: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Display book list
|
||||
for i, book in enumerate(books):
|
||||
print(f" [{i}] {book['title']} by {book['author']}")
|
||||
|
||||
# Render library view
|
||||
print("\nRendering library view...")
|
||||
library_image = library.render_library()
|
||||
library_image.save("integration_01_library.png")
|
||||
print("✓ Saved library view to: integration_01_library.png")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 2: SIMULATE BOOK SELECTION - User taps on first book
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 2: BOOK SELECTION - Simulating tap on first book")
|
||||
print("-" * 70)
|
||||
|
||||
# Simulate a tap on the first book row
|
||||
# Row positions depend on rendering, but first book is typically near top
|
||||
# We'll tap in the middle of the first book row area
|
||||
tap_x, tap_y = 400, 150 # Approximate center of first book row
|
||||
|
||||
print(f"Simulating tap at ({tap_x}, {tap_y})...")
|
||||
selected_book_path = library.handle_library_tap(tap_x, tap_y)
|
||||
|
||||
if not selected_book_path:
|
||||
print("Warning: Tap didn't hit a book. Selecting first book directly...")
|
||||
selected_book_path = books[0]['path']
|
||||
|
||||
print(f"✓ Selected book: {selected_book_path}")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 3: READING MODE - Open the selected book
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 3: READING MODE - Opening selected book")
|
||||
print("-" * 70)
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1200),
|
||||
margin=40,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the EPUB
|
||||
print(f"Loading: {selected_book_path}")
|
||||
success = reader.load_epub(selected_book_path)
|
||||
|
||||
if not success:
|
||||
print("Error: Failed to load EPUB")
|
||||
sys.exit(1)
|
||||
|
||||
print("✓ Book loaded successfully")
|
||||
|
||||
# Get book info
|
||||
book_info = reader.get_book_info()
|
||||
print(f" Title: {book_info['title']}")
|
||||
print(f" Author: {book_info['author']}")
|
||||
print(f" Chapters: {book_info['total_chapters']}")
|
||||
|
||||
# Render first page
|
||||
print("\nRendering first page...")
|
||||
page_image = reader.get_current_page()
|
||||
page_image.save("integration_02_reading_page1.png")
|
||||
print("✓ Saved first page to: integration_02_reading_page1.png")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 4: PAGE NAVIGATION - Turn some pages
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 4: PAGE NAVIGATION - Simulating page turns")
|
||||
print("-" * 70)
|
||||
|
||||
# Simulate swipe left (next page)
|
||||
print("Simulating SWIPE_LEFT (next page)...")
|
||||
touch_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600)
|
||||
response = reader.handle_touch(touch_event)
|
||||
|
||||
if response.action == ActionType.PAGE_TURN:
|
||||
print(f"✓ Page turned: {response.data}")
|
||||
page_image = reader.get_current_page()
|
||||
page_image.save("integration_03_reading_page2.png")
|
||||
print(" Saved to: integration_03_reading_page2.png")
|
||||
|
||||
# Turn another page
|
||||
print("\nSimulating another SWIPE_LEFT...")
|
||||
touch_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600)
|
||||
response = reader.handle_touch(touch_event)
|
||||
|
||||
if response.action == ActionType.PAGE_TURN:
|
||||
print(f"✓ Page turned: {response.data}")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 5: SETTINGS OVERLAY - Open and adjust settings
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 5: SETTINGS OVERLAY - Opening settings")
|
||||
print("-" * 70)
|
||||
|
||||
# Open settings overlay
|
||||
print("Opening settings overlay...")
|
||||
overlay_image = reader.open_settings_overlay()
|
||||
|
||||
if overlay_image:
|
||||
overlay_image.save("integration_04_settings_overlay.png")
|
||||
print("✓ Settings overlay opened")
|
||||
print(" Saved to: integration_04_settings_overlay.png")
|
||||
|
||||
# Simulate tapping "Increase Font Size" button
|
||||
print("\nSimulating tap on 'Increase Font Size'...")
|
||||
# The increase button is typically around y=250-280 in the overlay
|
||||
tap_x, tap_y = 400, 270
|
||||
touch_event = TouchEvent(GestureType.TAP, tap_x, tap_y)
|
||||
response = reader.handle_touch(touch_event)
|
||||
|
||||
if response.action == ActionType.SETTING_CHANGED:
|
||||
print(f"✓ Setting changed: {response.data}")
|
||||
updated_overlay = reader.get_current_page()
|
||||
updated_overlay.save("integration_05_settings_font_increased.png")
|
||||
print(" Saved updated overlay to: integration_05_settings_font_increased.png")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 6: BACK TO LIBRARY - Use the new "Back to Library" button
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 6: BACK TO LIBRARY - Using 'Back to Library' button")
|
||||
print("-" * 70)
|
||||
|
||||
# The settings overlay is 60% width x 70% height, centered
|
||||
# For 800x1200: panel is 480x840, offset at (160, 180)
|
||||
# The "Back to Library" button is near the bottom of the overlay panel
|
||||
# Let's try scanning for it by querying multiple y-positions
|
||||
|
||||
print("Scanning for 'Back to Library' button...")
|
||||
found_button = False
|
||||
|
||||
# Scan a wider range with finer granularity
|
||||
# Settings overlay is 60% x 70% of 800x1200 = 480x840, centered at (160, 180)
|
||||
# So overlay goes from y=180 to y=1020
|
||||
# Button should be near bottom, scan from y=600 to y=1020
|
||||
debug_results = []
|
||||
for test_y in range(600, 1021, 20):
|
||||
test_x = 400 # Center of screen
|
||||
|
||||
# Use the overlay manager's query method if there's an overlay open
|
||||
if hasattr(reader, 'overlay_manager'):
|
||||
result = reader.overlay_manager.query_overlay_pixel(test_x, test_y)
|
||||
|
||||
if result:
|
||||
debug_results.append((test_y, result.get("link_target"), result.get("text", "")[:30]))
|
||||
|
||||
if result.get("is_interactive") and result.get("link_target"):
|
||||
link = result["link_target"]
|
||||
if link == "action:back_to_library":
|
||||
print(f"✓ Found button at approximately ({test_x}, {test_y})")
|
||||
tap_x, tap_y = test_x, test_y
|
||||
found_button = True
|
||||
break
|
||||
|
||||
if not found_button and debug_results:
|
||||
print(f" Debug: Scanned {len(debug_results)} positions, found these links:")
|
||||
for y, link, text in debug_results[-5:]: # Show last 5
|
||||
if link:
|
||||
print(f" y={y}: link={link}, text='{text}'")
|
||||
|
||||
if not found_button:
|
||||
print(" Button not found via scan, using estimated position...")
|
||||
# Fallback: overlay height is 840, centered at y=180
|
||||
# Button is near bottom, approximately at panel_y + panel_height - 100
|
||||
tap_x, tap_y = 400, 900
|
||||
|
||||
print(f"Simulating tap at ({tap_x}, {tap_y})...")
|
||||
touch_event = TouchEvent(GestureType.TAP, tap_x, tap_y)
|
||||
response = reader.handle_touch(touch_event)
|
||||
|
||||
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||
print("✓ BACK_TO_LIBRARY action received!")
|
||||
print(" Application would now:")
|
||||
print(" 1. Close the current book")
|
||||
print(" 2. Return to library view")
|
||||
print(" 3. Save reading position for resume")
|
||||
|
||||
# Save current position for resume
|
||||
reader.save_position("__auto_resume__")
|
||||
print("\n ✓ Auto-resume position saved")
|
||||
|
||||
# Close the reader
|
||||
reader.close()
|
||||
print(" ✓ Book closed")
|
||||
|
||||
# Re-render library
|
||||
print("\n Re-rendering library view...")
|
||||
library_image = library.render_library()
|
||||
library_image.save("integration_06_back_to_library.png")
|
||||
print(" ✓ Saved library view to: integration_06_back_to_library.png")
|
||||
else:
|
||||
print(f"Unexpected response: {response.action}")
|
||||
print("Note: The button might be outside the overlay area or coordinates need adjustment")
|
||||
|
||||
# ===================================================================
|
||||
# STEP 7: SELECT ANOTHER BOOK (if multiple books available)
|
||||
# ===================================================================
|
||||
if len(books) > 1:
|
||||
print_separator()
|
||||
print("STEP 7: SELECTING ANOTHER BOOK")
|
||||
print("-" * 70)
|
||||
|
||||
# Select second book
|
||||
second_book_path = books[1]['path']
|
||||
print(f"Selecting second book: {second_book_path}")
|
||||
|
||||
# Create new reader instance
|
||||
reader2 = EbookReader(
|
||||
page_size=(800, 1200),
|
||||
margin=40,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load second book
|
||||
success = reader2.load_epub(second_book_path)
|
||||
|
||||
if success:
|
||||
book_info = reader2.get_book_info()
|
||||
print(f"✓ Loaded: {book_info['title']} by {book_info['author']}")
|
||||
|
||||
# Render first page
|
||||
page_image = reader2.get_current_page()
|
||||
page_image.save("integration_07_second_book.png")
|
||||
print(" Saved to: integration_07_second_book.png")
|
||||
|
||||
reader2.close()
|
||||
|
||||
# ===================================================================
|
||||
# STEP 8: RESUME PREVIOUS BOOK (demonstrate auto-resume)
|
||||
# ===================================================================
|
||||
print_separator()
|
||||
print("STEP 8: AUTO-RESUME - Reopening first book at saved position")
|
||||
print("-" * 70)
|
||||
|
||||
# Create new reader
|
||||
reader3 = EbookReader(
|
||||
page_size=(800, 1200),
|
||||
margin=40,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the book
|
||||
print(f"Reloading: {selected_book_path}")
|
||||
success = reader3.load_epub(selected_book_path)
|
||||
|
||||
if success:
|
||||
# Load auto-resume position
|
||||
print("Loading auto-resume position...")
|
||||
page = reader3.load_position("__auto_resume__")
|
||||
|
||||
if page:
|
||||
print("✓ Resumed at saved position!")
|
||||
pos_info = reader3.get_position_info()
|
||||
print(f" Progress: {pos_info['progress']*100:.1f}%")
|
||||
|
||||
page.save("integration_08_resumed_position.png")
|
||||
print(" Saved to: integration_08_resumed_position.png")
|
||||
else:
|
||||
print("No saved position found (started from beginning)")
|
||||
|
||||
reader3.close()
|
||||
|
||||
# Cleanup
|
||||
library.cleanup()
|
||||
|
||||
print_separator()
|
||||
print("✓ INTEGRATION TEST COMPLETE!")
|
||||
print_separator()
|
||||
print("\nGenerated demonstration images:")
|
||||
demo_files = [
|
||||
"integration_01_library.png",
|
||||
"integration_02_reading_page1.png",
|
||||
"integration_03_reading_page2.png",
|
||||
"integration_04_settings_overlay.png",
|
||||
"integration_05_settings_font_increased.png",
|
||||
"integration_06_back_to_library.png",
|
||||
"integration_07_second_book.png",
|
||||
"integration_08_resumed_position.png"
|
||||
]
|
||||
|
||||
for filename in demo_files:
|
||||
if os.path.exists(filename):
|
||||
print(f" ✓ {filename}")
|
||||
|
||||
print("\nThis demonstrates the complete workflow:")
|
||||
print(" 1. Library view with book selection")
|
||||
print(" 2. Opening and reading a book")
|
||||
print(" 3. Page navigation")
|
||||
print(" 4. Settings overlay with adjustments")
|
||||
print(" 5. Back to library transition")
|
||||
print(" 6. Selecting another book")
|
||||
print(" 7. Auto-resume functionality")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python library_reading_integration.py path/to/library/directory")
|
||||
print("\nThis demo requires a directory containing EPUB files.")
|
||||
print("\nExample:")
|
||||
print(" mkdir my_library")
|
||||
print(" cp tests/data/test.epub my_library/")
|
||||
print(" cp tests/data/test2.epub my_library/")
|
||||
print(" python library_reading_integration.py my_library/")
|
||||
sys.exit(1)
|
||||
|
||||
library_path = sys.argv[1]
|
||||
|
||||
if not os.path.exists(library_path):
|
||||
print(f"Error: Directory not found: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(library_path):
|
||||
print(f"Error: Not a directory: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
simulate_mode_transition_workflow(library_path)
|
||||
except Exception as e:
|
||||
print(f"\nError during integration test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Example demonstrating the unified navigation overlay feature.
|
||||
|
||||
This example shows how to:
|
||||
1. Open the navigation overlay with Contents and Bookmarks tabs
|
||||
2. Switch between tabs
|
||||
3. Navigate to chapters and bookmarks
|
||||
4. Handle user interactions with the overlay
|
||||
|
||||
The navigation overlay replaces the separate TOC and Bookmarks overlays
|
||||
with a single, unified interface that provides both features in a tabbed view.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from dreader.application import EbookReader
|
||||
from dreader.state import OverlayState
|
||||
|
||||
def main():
|
||||
# Create reader instance
|
||||
reader = EbookReader(page_size=(800, 1200), margin=20)
|
||||
|
||||
# Load a sample book (adjust path as needed)
|
||||
book_path = Path(__file__).parent / "books" / "hamlet.epub"
|
||||
if not book_path.exists():
|
||||
print(f"Book not found at {book_path}")
|
||||
print("Creating a simple HTML book for demo...")
|
||||
|
||||
# Create a simple multi-chapter book
|
||||
html = """
|
||||
<html>
|
||||
<head><title>Demo Book</title></head>
|
||||
<body>
|
||||
<h1>Chapter 1: Introduction</h1>
|
||||
<p>This is the first chapter with some introductory content.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
|
||||
<h1>Chapter 2: Main Content</h1>
|
||||
<p>This is the second chapter with main content.</p>
|
||||
<p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
||||
|
||||
<h1>Chapter 3: Conclusion</h1>
|
||||
<p>This is the final chapter with concluding remarks.</p>
|
||||
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
reader.load_html(
|
||||
html_string=html,
|
||||
title="Demo Book",
|
||||
author="Example Author",
|
||||
document_id="demo_navigation"
|
||||
)
|
||||
else:
|
||||
print(f"Loading book: {book_path}")
|
||||
reader.load_epub(str(book_path))
|
||||
|
||||
print("\n=== Navigation Overlay Demo ===\n")
|
||||
|
||||
# Display current page
|
||||
position_info = reader.get_position_info()
|
||||
print(f"Current position: {position_info}")
|
||||
print(f"Reading progress: {reader.get_reading_progress():.1%}")
|
||||
|
||||
# Get chapters
|
||||
chapters = reader.get_chapters()
|
||||
print(f"\nAvailable chapters: {len(chapters)}")
|
||||
for i, (title, idx) in enumerate(chapters[:5]): # Show first 5
|
||||
print(f" {i+1}. {title}")
|
||||
|
||||
# Save some bookmarks for demonstration
|
||||
print("\n--- Saving bookmarks ---")
|
||||
reader.save_position("Start of Book")
|
||||
print("Saved bookmark: 'Start of Book'")
|
||||
|
||||
reader.next_page()
|
||||
reader.next_page()
|
||||
reader.save_position("Chapter 1 Progress")
|
||||
print("Saved bookmark: 'Chapter 1 Progress'")
|
||||
|
||||
# List saved bookmarks
|
||||
bookmarks = reader.list_saved_positions()
|
||||
print(f"\nTotal bookmarks: {len(bookmarks)}")
|
||||
for name in bookmarks:
|
||||
print(f" - {name}")
|
||||
|
||||
# === Demo 1: Open navigation overlay with Contents tab ===
|
||||
print("\n\n--- Demo 1: Opening Navigation Overlay (Contents Tab) ---")
|
||||
image = reader.open_navigation_overlay(active_tab="contents")
|
||||
|
||||
if image:
|
||||
print(f"✓ Navigation overlay opened successfully")
|
||||
print(f" Overlay state: {reader.get_overlay_state()}")
|
||||
print(f" Is overlay open: {reader.is_overlay_open()}")
|
||||
print(f" Image size: {image.size}")
|
||||
|
||||
# Save the rendered overlay for inspection
|
||||
output_path = Path("/tmp/navigation_overlay_contents.png")
|
||||
image.save(output_path)
|
||||
print(f" Saved to: {output_path}")
|
||||
|
||||
# === Demo 2: Switch to Bookmarks tab ===
|
||||
print("\n\n--- Demo 2: Switching to Bookmarks Tab ---")
|
||||
image = reader.switch_navigation_tab("bookmarks")
|
||||
|
||||
if image:
|
||||
print(f"✓ Switched to Bookmarks tab")
|
||||
print(f" Overlay state: {reader.get_overlay_state()}")
|
||||
|
||||
# Save the rendered overlay for inspection
|
||||
output_path = Path("/tmp/navigation_overlay_bookmarks.png")
|
||||
image.save(output_path)
|
||||
print(f" Saved to: {output_path}")
|
||||
|
||||
# === Demo 3: Switch back to Contents tab ===
|
||||
print("\n\n--- Demo 3: Switching back to Contents Tab ---")
|
||||
image = reader.switch_navigation_tab("contents")
|
||||
|
||||
if image:
|
||||
print(f"✓ Switched back to Contents tab")
|
||||
|
||||
# Save the rendered overlay for inspection
|
||||
output_path = Path("/tmp/navigation_overlay_contents_2.png")
|
||||
image.save(output_path)
|
||||
print(f" Saved to: {output_path}")
|
||||
|
||||
# === Demo 4: Close overlay ===
|
||||
print("\n\n--- Demo 4: Closing Navigation Overlay ---")
|
||||
image = reader.close_overlay()
|
||||
|
||||
if image:
|
||||
print(f"✓ Overlay closed successfully")
|
||||
print(f" Overlay state: {reader.get_overlay_state()}")
|
||||
print(f" Is overlay open: {reader.is_overlay_open()}")
|
||||
|
||||
# === Demo 5: Open with Bookmarks tab directly ===
|
||||
print("\n\n--- Demo 5: Opening directly to Bookmarks Tab ---")
|
||||
image = reader.open_navigation_overlay(active_tab="bookmarks")
|
||||
|
||||
if image:
|
||||
print(f"✓ Navigation overlay opened with Bookmarks tab")
|
||||
|
||||
# Save the rendered overlay for inspection
|
||||
output_path = Path("/tmp/navigation_overlay_bookmarks_direct.png")
|
||||
image.save(output_path)
|
||||
print(f" Saved to: {output_path}")
|
||||
|
||||
# Close overlay
|
||||
reader.close_overlay()
|
||||
|
||||
# === Demo 6: Simulate user interaction flow ===
|
||||
print("\n\n--- Demo 6: Simulated User Interaction Flow ---")
|
||||
print("Simulating: User opens overlay, switches tabs, selects bookmark")
|
||||
|
||||
# 1. User opens navigation overlay
|
||||
print("\n 1. User taps navigation button -> Opens overlay with Contents tab")
|
||||
reader.open_navigation_overlay(active_tab="contents")
|
||||
print(f" State: {reader.get_overlay_state()}")
|
||||
|
||||
# 2. User switches to Bookmarks tab
|
||||
print("\n 2. User taps 'Bookmarks' tab")
|
||||
reader.switch_navigation_tab("bookmarks")
|
||||
print(f" State: {reader.get_overlay_state()}")
|
||||
|
||||
# 3. User selects a bookmark
|
||||
print("\n 3. User taps on bookmark 'Start of Book'")
|
||||
page = reader.load_position("Start of Book")
|
||||
if page:
|
||||
print(f" ✓ Loaded bookmark successfully")
|
||||
print(f" Position: {reader.get_position_info()}")
|
||||
|
||||
# 4. Close overlay
|
||||
print("\n 4. System closes overlay after selection")
|
||||
reader.close_overlay()
|
||||
print(f" State: {reader.get_overlay_state()}")
|
||||
|
||||
# === Summary ===
|
||||
print("\n\n=== Demo Complete ===")
|
||||
print(f"\nGenerated overlay images in /tmp:")
|
||||
print(f" - navigation_overlay_contents.png")
|
||||
print(f" - navigation_overlay_bookmarks.png")
|
||||
print(f" - navigation_overlay_contents_2.png")
|
||||
print(f" - navigation_overlay_bookmarks_direct.png")
|
||||
|
||||
print("\n✓ Navigation overlay provides unified interface for:")
|
||||
print(" • Table of Contents (chapter navigation)")
|
||||
print(" • Bookmarks (saved positions)")
|
||||
print(" • Tab switching between Contents and Bookmarks")
|
||||
print(" • Consistent interaction patterns")
|
||||
|
||||
# Cleanup
|
||||
reader.close()
|
||||
print("\nReader closed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example demonstrating persistent rendering settings.
|
||||
|
||||
This shows how to:
|
||||
1. Initialize StateManager to load saved settings
|
||||
2. Apply saved settings to EbookReader
|
||||
3. Modify settings during reading
|
||||
4. Save settings automatically for next session
|
||||
|
||||
The settings (font size, line spacing, etc.) will persist between
|
||||
application sessions, so the user doesn't have to reconfigure each time.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader import EbookReader
|
||||
from dreader.state import StateManager, Settings
|
||||
|
||||
|
||||
def demonstrate_persistent_settings():
|
||||
"""Show how settings persist across sessions"""
|
||||
|
||||
print("=" * 70)
|
||||
print("Persistent Settings Example")
|
||||
print("=" * 70)
|
||||
|
||||
# 1. Initialize state manager (loads saved state from disk)
|
||||
state_file = Path.home() / ".config" / "dreader" / "state.json"
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state_manager = StateManager(state_file=state_file)
|
||||
state = state_manager.load_state()
|
||||
|
||||
print(f"\nLoaded settings from: {state_file}")
|
||||
print(f" Font scale: {state.settings.font_scale}")
|
||||
print(f" Line spacing: {state.settings.line_spacing}px")
|
||||
print(f" Inter-block spacing: {state.settings.inter_block_spacing}px")
|
||||
print(f" Word spacing: {state.settings.word_spacing}px")
|
||||
|
||||
# 2. Create reader with saved settings
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
line_spacing=state.settings.line_spacing,
|
||||
inter_block_spacing=state.settings.inter_block_spacing
|
||||
)
|
||||
|
||||
# Load a book
|
||||
epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub'
|
||||
epubs = list(epub_dir.glob('*.epub'))
|
||||
|
||||
if not epubs:
|
||||
print("\nError: No test EPUB files found!")
|
||||
print(f"Looked in: {epub_dir}")
|
||||
return
|
||||
|
||||
epub_path = epubs[0]
|
||||
print(f"\nLoading book: {epub_path.name}")
|
||||
|
||||
if not reader.load_epub(str(epub_path)):
|
||||
print("Failed to load book!")
|
||||
return
|
||||
|
||||
print(f"Loaded: {reader.book_title} by {reader.book_author}")
|
||||
|
||||
# 3. Apply saved settings to the book
|
||||
print("\nApplying saved settings to book...")
|
||||
settings_dict = state.settings.to_dict()
|
||||
reader.apply_settings(settings_dict)
|
||||
|
||||
# Render initial page
|
||||
print("\nRendering page with saved settings...")
|
||||
page = reader.get_current_page()
|
||||
reader.render_to_file("persistent_settings_before.png")
|
||||
print("✓ Saved: persistent_settings_before.png")
|
||||
|
||||
# 4. Simulate user changing settings
|
||||
print("\n" + "=" * 70)
|
||||
print("User adjusts settings...")
|
||||
print("=" * 70)
|
||||
|
||||
# Increase font size
|
||||
print("\n1. Increasing font size...")
|
||||
reader.increase_font_size()
|
||||
reader.increase_font_size()
|
||||
print(f" New font scale: {reader.base_font_scale}")
|
||||
|
||||
# Increase line spacing
|
||||
print("2. Increasing line spacing...")
|
||||
new_line_spacing = state.settings.line_spacing + 4
|
||||
reader.set_line_spacing(new_line_spacing)
|
||||
print(f" New line spacing: {new_line_spacing}px")
|
||||
|
||||
# Increase word spacing
|
||||
print("3. Increasing word spacing...")
|
||||
new_word_spacing = state.settings.word_spacing + 3
|
||||
reader.set_word_spacing(new_word_spacing)
|
||||
print(f" New word spacing: {new_word_spacing}px")
|
||||
|
||||
# Render page with new settings
|
||||
print("\nRendering page with new settings...")
|
||||
page = reader.get_current_page()
|
||||
reader.render_to_file("persistent_settings_after.png")
|
||||
print("✓ Saved: persistent_settings_after.png")
|
||||
|
||||
# 5. Save new settings to state
|
||||
print("\n" + "=" * 70)
|
||||
print("Saving settings for next session...")
|
||||
print("=" * 70)
|
||||
|
||||
current_settings = reader.get_current_settings()
|
||||
state_manager.update_settings(current_settings)
|
||||
|
||||
print(f"\nSettings to be saved:")
|
||||
print(f" Font scale: {current_settings['font_scale']}")
|
||||
print(f" Line spacing: {current_settings['line_spacing']}px")
|
||||
print(f" Inter-block spacing: {current_settings['inter_block_spacing']}px")
|
||||
print(f" Word spacing: {current_settings['word_spacing']}px")
|
||||
|
||||
# Save state to disk
|
||||
if state_manager.save_state():
|
||||
print(f"\n✓ Settings saved to: {state_file}")
|
||||
print(" These settings will be used the next time you open a book!")
|
||||
else:
|
||||
print("\n✗ Failed to save settings")
|
||||
|
||||
# 6. Demonstrate that settings are saved
|
||||
print("\n" + "=" * 70)
|
||||
print("Verification: Reloading state from disk...")
|
||||
print("=" * 70)
|
||||
|
||||
# Create new state manager to verify persistence
|
||||
verification_manager = StateManager(state_file=state_file)
|
||||
verification_state = verification_manager.load_state()
|
||||
|
||||
print(f"\nVerified saved settings:")
|
||||
print(f" Font scale: {verification_state.settings.font_scale}")
|
||||
print(f" Line spacing: {verification_state.settings.line_spacing}px")
|
||||
print(f" Inter-block spacing: {verification_state.settings.inter_block_spacing}px")
|
||||
print(f" Word spacing: {verification_state.settings.word_spacing}px")
|
||||
|
||||
if (verification_state.settings.font_scale == current_settings['font_scale'] and
|
||||
verification_state.settings.line_spacing == current_settings['line_spacing'] and
|
||||
verification_state.settings.word_spacing == current_settings['word_spacing']):
|
||||
print("\n✓ Settings successfully persisted!")
|
||||
else:
|
||||
print("\n✗ Settings mismatch!")
|
||||
|
||||
# Cleanup
|
||||
reader.close()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Demo Complete!")
|
||||
print("=" * 70)
|
||||
print("\nKey Points:")
|
||||
print(" • Settings are automatically loaded from ~/.config/dreader/state.json")
|
||||
print(" • Use reader.apply_settings() to apply saved settings after loading a book")
|
||||
print(" • Use reader.get_current_settings() to get current settings")
|
||||
print(" • Use state_manager.update_settings() to save new settings")
|
||||
print(" • Settings persist across application restarts")
|
||||
print("\nGenerated files:")
|
||||
print(" • persistent_settings_before.png - Page with original settings")
|
||||
print(" • persistent_settings_after.png - Page with modified settings")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
demonstrate_persistent_settings()
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run DReader on real e-ink hardware.
|
||||
|
||||
This example demonstrates running the DReader application on real e-ink hardware
|
||||
using the dreader-hal library for hardware abstraction.
|
||||
|
||||
Requirements:
|
||||
- Raspberry Pi (or compatible SBC)
|
||||
- IT8951 e-ink display
|
||||
- FT5xx6 capacitive touch sensor
|
||||
- Optional: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor
|
||||
|
||||
Hardware Setup:
|
||||
See external/dreader-hal/README.md for wiring instructions
|
||||
|
||||
Usage:
|
||||
# On Raspberry Pi with full hardware
|
||||
python run_on_hardware.py /path/to/library
|
||||
|
||||
# For testing without hardware (virtual display mode)
|
||||
python run_on_hardware.py /path/to/library --virtual
|
||||
|
||||
# Disable optional components
|
||||
python run_on_hardware.py /path/to/library --no-orientation --no-rtc --no-power
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path to import dreader
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
|
||||
async def main(args):
|
||||
"""
|
||||
Main application entry point.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
"""
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting DReader on hardware")
|
||||
logger.info(f"Library path: {args.library_path}")
|
||||
logger.info(f"Display size: {args.width}x{args.height}")
|
||||
logger.info(f"VCOM: {args.vcom}V")
|
||||
logger.info(f"Virtual display: {args.virtual}")
|
||||
|
||||
# Create hardware HAL
|
||||
logger.info("Initializing hardware HAL...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
vcom=args.vcom,
|
||||
virtual_display=args.virtual,
|
||||
auto_sleep_display=args.auto_sleep,
|
||||
enable_orientation=args.orientation,
|
||||
enable_rtc=args.rtc,
|
||||
enable_power_monitor=args.power,
|
||||
battery_capacity_mah=args.battery_capacity,
|
||||
)
|
||||
|
||||
# Create application config
|
||||
config = AppConfig(
|
||||
display_hal=hal,
|
||||
library_path=args.library_path,
|
||||
page_size=(args.width, args.height),
|
||||
auto_save_interval=60,
|
||||
force_library_mode=args.force_library,
|
||||
log_level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
)
|
||||
|
||||
# Create application
|
||||
app = DReaderApplication(config)
|
||||
|
||||
try:
|
||||
# Initialize hardware
|
||||
logger.info("Initializing hardware...")
|
||||
await hal.initialize()
|
||||
|
||||
# Start application
|
||||
logger.info("Starting application...")
|
||||
await app.start()
|
||||
|
||||
# Show battery level if available
|
||||
if args.power and not args.virtual:
|
||||
try:
|
||||
battery = await hal.get_battery_level()
|
||||
logger.info(f"Battery level: {battery:.1f}%")
|
||||
|
||||
if await hal.is_low_battery():
|
||||
logger.warning("⚠️ Low battery!")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read battery: {e}")
|
||||
|
||||
# Main event loop
|
||||
logger.info("Entering main event loop (Ctrl+C to exit)")
|
||||
logger.info("")
|
||||
logger.info("Touch gestures:")
|
||||
logger.info(" - Swipe left: Next page")
|
||||
logger.info(" - Swipe right: Previous page")
|
||||
logger.info(" - Swipe up (from bottom): Open navigation/TOC")
|
||||
logger.info(" - Swipe down (from top): Open settings")
|
||||
logger.info(" - Tap: Select book/word/link")
|
||||
logger.info("")
|
||||
|
||||
while app.is_running():
|
||||
# Get touch event (non-blocking)
|
||||
event = await hal.get_touch_event()
|
||||
|
||||
if event:
|
||||
logger.debug(f"Touch event: {event.gesture.value} at ({event.x}, {event.y})")
|
||||
|
||||
# Handle touch event
|
||||
await app.handle_touch(event)
|
||||
|
||||
# Check battery periodically (every ~100 events)
|
||||
if args.power and not args.virtual and args.show_battery:
|
||||
if hasattr(app, '_event_count'):
|
||||
app._event_count += 1
|
||||
else:
|
||||
app._event_count = 1
|
||||
|
||||
if app._event_count % 100 == 0:
|
||||
battery = await hal.get_battery_level()
|
||||
logger.info(f"Battery: {battery:.1f}%")
|
||||
|
||||
# Small delay to prevent CPU spinning
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal, shutting down...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in main loop: {e}", exc_info=True)
|
||||
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down application...")
|
||||
await app.shutdown()
|
||||
|
||||
logger.info("Cleaning up hardware...")
|
||||
await hal.cleanup()
|
||||
|
||||
logger.info("DReader stopped")
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run DReader on e-ink hardware",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run on real hardware
|
||||
%(prog)s /home/pi/Books
|
||||
|
||||
# Test with virtual display (no hardware required)
|
||||
%(prog)s /home/pi/Books --virtual
|
||||
|
||||
# Custom display size and VCOM
|
||||
%(prog)s /home/pi/Books --width 1200 --height 1600 --vcom -2.3
|
||||
|
||||
# Disable optional sensors
|
||||
%(prog)s /home/pi/Books --no-orientation --no-rtc --no-power
|
||||
"""
|
||||
)
|
||||
|
||||
# Required arguments
|
||||
parser.add_argument(
|
||||
'library_path',
|
||||
type=str,
|
||||
help='Path to directory containing EPUB files'
|
||||
)
|
||||
|
||||
# Display arguments
|
||||
parser.add_argument(
|
||||
'--width',
|
||||
type=int,
|
||||
default=1872,
|
||||
help='Display width in pixels (default: 1872)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--height',
|
||||
type=int,
|
||||
default=1404,
|
||||
help='Display height in pixels (default: 1404)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--vcom',
|
||||
type=float,
|
||||
default=-2.0,
|
||||
help='E-ink VCOM voltage - CHECK YOUR DISPLAY LABEL! (default: -2.0)'
|
||||
)
|
||||
|
||||
# Virtual display mode
|
||||
parser.add_argument(
|
||||
'--virtual',
|
||||
action='store_true',
|
||||
help='Use virtual display mode for testing without hardware'
|
||||
)
|
||||
|
||||
# Display features
|
||||
parser.add_argument(
|
||||
'--no-auto-sleep',
|
||||
dest='auto_sleep',
|
||||
action='store_false',
|
||||
help='Disable automatic display sleep after updates'
|
||||
)
|
||||
|
||||
# Optional hardware components
|
||||
parser.add_argument(
|
||||
'--no-orientation',
|
||||
dest='orientation',
|
||||
action='store_false',
|
||||
help='Disable orientation sensor (BMA400)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-rtc',
|
||||
dest='rtc',
|
||||
action='store_false',
|
||||
help='Disable RTC (PCF8523)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-power',
|
||||
dest='power',
|
||||
action='store_false',
|
||||
help='Disable power monitor (INA219)'
|
||||
)
|
||||
|
||||
# Battery monitoring
|
||||
parser.add_argument(
|
||||
'--battery-capacity',
|
||||
type=float,
|
||||
default=3000,
|
||||
help='Battery capacity in mAh (default: 3000)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--show-battery',
|
||||
action='store_true',
|
||||
help='Periodically log battery level'
|
||||
)
|
||||
|
||||
# Application behavior
|
||||
parser.add_argument(
|
||||
'--force-library',
|
||||
action='store_true',
|
||||
help='Always start in library mode (ignore saved state)'
|
||||
)
|
||||
|
||||
# Debugging
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose debug logging'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate library path
|
||||
library_path = Path(args.library_path).expanduser()
|
||||
if not library_path.exists():
|
||||
parser.error(f"Library path does not exist: {library_path}")
|
||||
if not library_path.is_dir():
|
||||
parser.error(f"Library path is not a directory: {library_path}")
|
||||
|
||||
args.library_path = str(library_path)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
|
||||
# Run async main
|
||||
try:
|
||||
asyncio.run(main(args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run DReader on hardware using hardware_config.json configuration.
|
||||
|
||||
This script loads all hardware configuration from hardware_config.json,
|
||||
including display settings, GPIO buttons, and optional components.
|
||||
|
||||
Usage:
|
||||
# Use default config file (hardware_config.json)
|
||||
python run_on_hardware_config.py
|
||||
|
||||
# Use custom config file
|
||||
python run_on_hardware_config.py --config my_config.json
|
||||
|
||||
# Override config settings
|
||||
python run_on_hardware_config.py --library ~/MyBooks --verbose
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path to import dreader
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
from dreader.gpio_buttons import load_button_config_from_dict
|
||||
|
||||
|
||||
def load_config(config_path: str) -> dict:
|
||||
"""Load hardware configuration from JSON file."""
|
||||
config_file = Path(config_path)
|
||||
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found: {config_path}\n"
|
||||
f"Run 'sudo python3 setup_rpi.py' to create it."
|
||||
)
|
||||
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def main(args):
|
||||
"""Main application entry point."""
|
||||
# Load configuration
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Loading configuration from {args.config}")
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Apply command-line overrides
|
||||
if args.library:
|
||||
config['application']['library_path'] = args.library
|
||||
|
||||
if args.verbose:
|
||||
config['application']['log_level'] = 'DEBUG'
|
||||
|
||||
# Set up logging
|
||||
log_level = getattr(logging, config['application']['log_level'].upper())
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
logger.info("="*70)
|
||||
logger.info("DReader Hardware Mode")
|
||||
logger.info("="*70)
|
||||
|
||||
# Display configuration summary
|
||||
display_cfg = config['display']
|
||||
logger.info(f"Display: {display_cfg['width']}x{display_cfg['height']}, VCOM={display_cfg['vcom']}V")
|
||||
|
||||
gpio_cfg = config.get('gpio_buttons', {})
|
||||
if gpio_cfg.get('enabled', False):
|
||||
logger.info(f"GPIO Buttons: {len(gpio_cfg.get('buttons', []))} configured")
|
||||
|
||||
accel_cfg = config.get('accelerometer', {})
|
||||
if accel_cfg.get('enabled', False):
|
||||
logger.info("Accelerometer: Enabled")
|
||||
|
||||
rtc_cfg = config.get('rtc', {})
|
||||
if rtc_cfg.get('enabled', False):
|
||||
logger.info("RTC: Enabled")
|
||||
|
||||
power_cfg = config.get('power_monitor', {})
|
||||
if power_cfg.get('enabled', False):
|
||||
logger.info("Power Monitor: Enabled")
|
||||
|
||||
# Create hardware HAL
|
||||
logger.info("\nInitializing hardware HAL...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=display_cfg['width'],
|
||||
height=display_cfg['height'],
|
||||
vcom=display_cfg['vcom'],
|
||||
spi_hz=display_cfg.get('spi_hz', 24_000_000),
|
||||
virtual_display=False,
|
||||
auto_sleep_display=display_cfg.get('auto_sleep', True),
|
||||
enable_orientation=accel_cfg.get('enabled', True),
|
||||
enable_rtc=rtc_cfg.get('enabled', True),
|
||||
enable_power_monitor=power_cfg.get('enabled', True),
|
||||
shunt_ohms=power_cfg.get('shunt_ohms', 0.1),
|
||||
battery_capacity_mah=power_cfg.get('battery_capacity_mah', 3000),
|
||||
)
|
||||
|
||||
# Load accelerometer tilt calibration if enabled
|
||||
if accel_cfg.get('tilt_enabled', False):
|
||||
calib_file = accel_cfg.get('calibration_file', 'accelerometer_config.json')
|
||||
if hal.load_accelerometer_calibration(calib_file):
|
||||
logger.info(f"Accelerometer tilt detection enabled (calibration from {calib_file})")
|
||||
else:
|
||||
logger.warning("Accelerometer tilt detection requested but calibration not loaded")
|
||||
|
||||
# Set up GPIO buttons
|
||||
button_handler = None
|
||||
if gpio_cfg.get('enabled', False):
|
||||
logger.info("Setting up GPIO buttons...")
|
||||
button_handler = load_button_config_from_dict(
|
||||
config,
|
||||
screen_width=display_cfg['width'],
|
||||
screen_height=display_cfg['height']
|
||||
)
|
||||
|
||||
if button_handler:
|
||||
await button_handler.initialize()
|
||||
logger.info(f"GPIO buttons initialized: {len(gpio_cfg.get('buttons', []))} buttons")
|
||||
|
||||
# Create application config
|
||||
app_cfg = config['application']
|
||||
app_config = AppConfig(
|
||||
display_hal=hal,
|
||||
library_path=app_cfg['library_path'],
|
||||
page_size=(display_cfg['width'], display_cfg['height']),
|
||||
auto_save_interval=app_cfg.get('auto_save_interval', 60),
|
||||
force_library_mode=app_cfg.get('force_library_mode', False),
|
||||
log_level=log_level,
|
||||
)
|
||||
|
||||
# Create application
|
||||
app = DReaderApplication(app_config)
|
||||
|
||||
try:
|
||||
# Initialize hardware
|
||||
logger.info("Initializing hardware...")
|
||||
await hal.initialize()
|
||||
|
||||
# Start application
|
||||
logger.info("Starting application...")
|
||||
await app.start()
|
||||
|
||||
# Show battery level if available
|
||||
if power_cfg.get('enabled', False):
|
||||
try:
|
||||
battery = await hal.get_battery_level()
|
||||
logger.info(f"Battery level: {battery:.1f}%")
|
||||
|
||||
if await hal.is_low_battery(power_cfg.get('low_battery_threshold', 20.0)):
|
||||
logger.warning("⚠️ Low battery!")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read battery: {e}")
|
||||
|
||||
# Main event loop
|
||||
logger.info("\nApplication ready!")
|
||||
logger.info("="*70)
|
||||
|
||||
event_count = 0
|
||||
show_battery_interval = power_cfg.get('show_battery_interval', 100)
|
||||
|
||||
while app.is_running():
|
||||
# Check for touch events
|
||||
touch_event = await hal.get_touch_event()
|
||||
|
||||
if touch_event:
|
||||
logger.debug(f"Touch: {touch_event.gesture.value} at ({touch_event.x}, {touch_event.y})")
|
||||
await app.handle_touch(touch_event)
|
||||
event_count += 1
|
||||
|
||||
# Check for button events
|
||||
if button_handler:
|
||||
button_event = await button_handler.get_button_event()
|
||||
if button_event:
|
||||
logger.info(f"Button: {button_event.gesture.value}")
|
||||
await app.handle_touch(button_event)
|
||||
event_count += 1
|
||||
|
||||
# Check for tilt gestures if enabled
|
||||
if accel_cfg.get('tilt_enabled', False):
|
||||
tilt_event = await hal.get_tilt_gesture()
|
||||
if tilt_event:
|
||||
logger.info(f"Tilt: {tilt_event.gesture.value}")
|
||||
await app.handle_touch(tilt_event)
|
||||
event_count += 1
|
||||
|
||||
# Show battery periodically
|
||||
if power_cfg.get('enabled', False) and event_count % show_battery_interval == 0 and event_count > 0:
|
||||
try:
|
||||
battery = await hal.get_battery_level()
|
||||
logger.info(f"Battery: {battery:.1f}%")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Small delay to prevent CPU spinning
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\nReceived interrupt signal, shutting down...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in main loop: {e}", exc_info=True)
|
||||
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down application...")
|
||||
await app.shutdown()
|
||||
|
||||
logger.info("Cleaning up GPIO buttons...")
|
||||
if button_handler:
|
||||
await button_handler.cleanup()
|
||||
|
||||
logger.info("Cleaning up hardware...")
|
||||
await hal.cleanup()
|
||||
|
||||
logger.info("DReader stopped")
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run DReader using hardware_config.json",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Configuration:
|
||||
Edit hardware_config.json to configure your hardware settings.
|
||||
Run 'sudo python3 setup_rpi.py' to create/update the config file.
|
||||
|
||||
Examples:
|
||||
# Use default config
|
||||
%(prog)s
|
||||
|
||||
# Use custom config file
|
||||
%(prog)s --config my_hardware.json
|
||||
|
||||
# Override library path
|
||||
%(prog)s --library ~/MyBooks
|
||||
|
||||
# Enable verbose logging
|
||||
%(prog)s --verbose
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
default='hardware_config.json',
|
||||
help='Path to hardware configuration file (default: hardware_config.json)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--library',
|
||||
type=str,
|
||||
help='Override library path from config'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose debug logging'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
|
||||
# Run async main
|
||||
try:
|
||||
asyncio.run(main(args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct IT8951 test - bypasses the HAL completely.
|
||||
Uses IT8951 library directly like the working example.
|
||||
"""
|
||||
|
||||
from IT8951 import constants
|
||||
from IT8951.display import AutoEPDDisplay
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def main():
|
||||
"""Test display with direct IT8951 access."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Direct IT8951 Test - Half Black, Half White")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("This test bypasses the HAL and uses IT8951 directly.")
|
||||
print("Matches the working code you provided.")
|
||||
print()
|
||||
|
||||
# Display size
|
||||
width, height = 1872, 1404
|
||||
shape = (height, width) # IT8951 uses (height, width) format
|
||||
|
||||
print(f"Display shape: {shape} (height, width)")
|
||||
print(f"Dimensions: {width}x{height}")
|
||||
print()
|
||||
|
||||
# Create test image
|
||||
print("Creating test image...")
|
||||
print(" Left half: BLACK (0)")
|
||||
print(" Right half: WHITE (255)")
|
||||
|
||||
# Create grayscale image
|
||||
screen = Image.new('L', shape, color=255) # White background
|
||||
|
||||
# Draw left half black
|
||||
pixels = screen.load()
|
||||
for y in range(height):
|
||||
for x in range(width // 2):
|
||||
pixels[x, y] = 0 # Black
|
||||
|
||||
print(f"✓ Image created: {screen.size} {screen.mode}")
|
||||
print()
|
||||
|
||||
# Save for reference
|
||||
output_file = "direct_test.png"
|
||||
screen.save(output_file)
|
||||
print(f"✓ Saved to: {output_file}")
|
||||
print()
|
||||
|
||||
# Initialize display
|
||||
print("Initializing IT8951 display...")
|
||||
print(" VCOM: -1.7V")
|
||||
print(" Rotation: CW (clockwise)")
|
||||
print(" SPI: 24MHz")
|
||||
|
||||
display = AutoEPDDisplay(
|
||||
vcom=-1.7,
|
||||
rotate="CW",
|
||||
spi_hz=24000000,
|
||||
device=0,
|
||||
bus=0
|
||||
)
|
||||
|
||||
print("✓ Display initialized")
|
||||
print()
|
||||
|
||||
# Display the image
|
||||
print("Displaying image...")
|
||||
print(" Using frame_buf.paste() + draw_full(GC16)")
|
||||
|
||||
display.frame_buf.paste(screen, (0, 0))
|
||||
display.draw_full(constants.DisplayModes.GC16)
|
||||
|
||||
print("✓ Image displayed!")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("CHECK YOUR SCREEN:")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("You should see:")
|
||||
print(" • LEFT HALF: BLACK")
|
||||
print(" • RIGHT HALF: WHITE")
|
||||
print()
|
||||
print("If this works, the display hardware is fine!")
|
||||
print("If this doesn't work, there's a hardware/wiring issue.")
|
||||
print()
|
||||
|
||||
# Sleep display
|
||||
input("Press Enter to put display to sleep and exit...")
|
||||
|
||||
epd = display.epd
|
||||
epd.sleep()
|
||||
|
||||
print("Display put to sleep. Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nTest interrupted")
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to demonstrate font family setting functionality.
|
||||
"""
|
||||
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
from dreader.application import EbookReader
|
||||
import os
|
||||
|
||||
def test_font_family():
|
||||
"""Test the font family setting feature."""
|
||||
# Initialize reader
|
||||
reader = EbookReader(page_size=(600, 800), margin=20)
|
||||
|
||||
# Load a sample book
|
||||
book_path = os.path.join(os.path.dirname(__file__), '..', 'examples', 'beowulf.epub')
|
||||
|
||||
if not os.path.exists(book_path):
|
||||
print(f"Book not found at {book_path}")
|
||||
print("Skipping book loading - testing with HTML instead...")
|
||||
# Load a simple HTML document instead
|
||||
sample_html = """
|
||||
<html>
|
||||
<head><title>Font Family Test</title></head>
|
||||
<body>
|
||||
<h1>Font Family Test Document</h1>
|
||||
<p>This is a test document to demonstrate the font family setting feature.</p>
|
||||
<p>The quick brown fox jumps over the lazy dog. 0123456789</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
reader.load_html(sample_html, title="Font Family Test")
|
||||
else:
|
||||
print(f"Loading book from: {book_path}")
|
||||
reader.load_epub(book_path)
|
||||
|
||||
# Get initial page
|
||||
print("\n1. Rendering with default font family...")
|
||||
page1 = reader.get_current_page()
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Switch to serif
|
||||
print("\n2. Switching to SERIF font family...")
|
||||
reader.set_font_family(BundledFont.SERIF)
|
||||
page2 = reader.get_current_page()
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Switch to sans-serif
|
||||
print("\n3. Switching to SANS font family...")
|
||||
reader.set_font_family(BundledFont.SANS)
|
||||
page3 = reader.get_current_page()
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Switch to monospace
|
||||
print("\n4. Switching to MONOSPACE font family...")
|
||||
reader.set_font_family(BundledFont.MONOSPACE)
|
||||
page4 = reader.get_current_page()
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Restore original fonts
|
||||
print("\n5. Restoring document default font family...")
|
||||
reader.set_font_family(None)
|
||||
page5 = reader.get_current_page()
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Test settings persistence
|
||||
print("\n6. Testing settings persistence...")
|
||||
reader.set_font_family(BundledFont.SERIF)
|
||||
settings = reader.get_current_settings()
|
||||
print(f" Settings: {settings}")
|
||||
print(f" Font family in settings: {settings.get('font_family')}")
|
||||
|
||||
# Apply settings
|
||||
print("\n7. Applying settings with MONOSPACE...")
|
||||
new_settings = settings.copy()
|
||||
new_settings['font_family'] = 'MONOSPACE'
|
||||
reader.apply_settings(new_settings)
|
||||
print(f" Current font family: {reader.get_font_family()}")
|
||||
|
||||
# Test with settings overlay
|
||||
print("\n8. Opening settings overlay...")
|
||||
overlay_image = reader.open_settings_overlay()
|
||||
print(f" Settings overlay opened successfully: {overlay_image is not None}")
|
||||
print(f" Settings overlay dimensions: {overlay_image.size if overlay_image else 'N/A'}")
|
||||
|
||||
print("\n✓ All font family tests passed!")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_font_family()
|
||||
except Exception as e:
|
||||
print(f"\n✗ Test failed with error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test to verify the hardware HAL can display images.
|
||||
Creates a test pattern and displays it on the e-ink screen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
|
||||
|
||||
def create_test_image(width=1404, height=1872 ):
|
||||
"""Create a test pattern image."""
|
||||
print("Creating test image...")
|
||||
|
||||
# Create white background
|
||||
img = Image.new('RGBA', (width, height), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw border
|
||||
border_width = 10
|
||||
draw.rectangle(
|
||||
[(border_width, border_width),
|
||||
(width - border_width, height - border_width)],
|
||||
outline='black',
|
||||
width=border_width
|
||||
)
|
||||
|
||||
# Draw title
|
||||
try:
|
||||
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 80)
|
||||
font_medium = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 50)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = ImageFont.load_default()
|
||||
|
||||
# Center text
|
||||
title = "Hardware Display Test"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_large)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
title_x = (width - title_width) // 2
|
||||
draw.text((title_x, 100), title, fill='black', font=font_large)
|
||||
|
||||
# Instructions
|
||||
instructions = [
|
||||
"If you can see this, the display is working!",
|
||||
"",
|
||||
"Test pattern includes:",
|
||||
"• Border around the screen",
|
||||
"• Centered text",
|
||||
"• Diagonal lines",
|
||||
"• Corner markers"
|
||||
]
|
||||
|
||||
y = 250
|
||||
for line in instructions:
|
||||
if line:
|
||||
bbox = draw.textbbox((0, 0), line, font=font_medium)
|
||||
line_width = bbox[2] - bbox[0]
|
||||
x = (width - line_width) // 2
|
||||
draw.text((x, y), line, fill='black', font=font_medium)
|
||||
y += 70
|
||||
|
||||
# Draw diagonal lines
|
||||
draw.line([(50, 50), (width-50, height-50)], fill='black', width=3)
|
||||
draw.line([(width-50, 50), (50, height-50)], fill='black', width=3)
|
||||
|
||||
# Draw corner markers
|
||||
marker_size = 100
|
||||
# Top-left
|
||||
draw.rectangle([(20, 20), (20 + marker_size, 20 + marker_size)],
|
||||
fill='black')
|
||||
draw.text((30, 30), "TL", fill='white', font=font_medium)
|
||||
|
||||
# Top-right
|
||||
draw.rectangle([(width - 20 - marker_size, 20),
|
||||
(width - 20, 20 + marker_size)],
|
||||
fill='black')
|
||||
draw.text((width - 100, 30), "TR", fill='white', font=font_medium)
|
||||
|
||||
# Bottom-left
|
||||
draw.rectangle([(20, height - 20 - marker_size),
|
||||
(20 + marker_size, height - 20)],
|
||||
fill='black')
|
||||
draw.text((30, height - 100), "BL", fill='white', font=font_medium)
|
||||
|
||||
# Bottom-right
|
||||
draw.rectangle([(width - 20 - marker_size, height - 20 - marker_size),
|
||||
(width - 20, height - 20)],
|
||||
fill='black')
|
||||
draw.text((width - 100, height - 100), "BR", fill='white', font=font_medium)
|
||||
|
||||
print(f"Test image created: {width}x{height} RGBA")
|
||||
return img
|
||||
|
||||
|
||||
async def main(args):
|
||||
"""Test hardware display."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Hardware Display Test")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Get VCOM from args
|
||||
vcom = args.vcom if hasattr(args, 'vcom') else -1.65
|
||||
print(f"Using VCOM: {vcom}V")
|
||||
print()
|
||||
|
||||
# Create test image
|
||||
test_image = create_test_image(1872, 1404)
|
||||
|
||||
# Save test image for reference
|
||||
output_file = "test_pattern.png"
|
||||
test_image.save(output_file)
|
||||
print(f"Test pattern saved to: {output_file}")
|
||||
print()
|
||||
|
||||
# Initialize hardware HAL
|
||||
print("Initializing hardware HAL...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
vcom=vcom,
|
||||
config_file="hardware_config.json"
|
||||
)
|
||||
|
||||
print("Initializing hardware components...")
|
||||
await hal.initialize()
|
||||
print("✓ Hardware initialized")
|
||||
print()
|
||||
|
||||
# Display the test image
|
||||
print("Displaying test image on e-ink screen...")
|
||||
print("(This may take a few seconds for the full refresh)")
|
||||
print(f"Converting image from {test_image.mode} to grayscale for e-ink...")
|
||||
|
||||
# Convert RGBA to grayscale (L mode) for e-ink display
|
||||
if test_image.mode == 'RGBA':
|
||||
# Convert RGBA to RGB first (flatten alpha)
|
||||
rgb_image = Image.new('RGB', test_image.size, (255, 255, 255))
|
||||
rgb_image.paste(test_image, mask=test_image.split()[3]) # Use alpha as mask
|
||||
test_image_gray = rgb_image.convert('L')
|
||||
else:
|
||||
test_image_gray = test_image.convert('L')
|
||||
|
||||
print(f"Image converted to {test_image_gray.mode} mode")
|
||||
await hal.show_image(test_image_gray)
|
||||
print("✓ Image displayed!")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("SUCCESS!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("If you can see the test pattern on the screen, the")
|
||||
print("hardware display is working correctly!")
|
||||
print()
|
||||
print("Check that you can see:")
|
||||
print(" • Black border around the edges")
|
||||
print(" • Title text centered at the top")
|
||||
print(" • Diagonal lines crossing the screen")
|
||||
print(" • Corner markers labeled TL, TR, BL, BR")
|
||||
print()
|
||||
print("Press Ctrl+C to exit")
|
||||
print()
|
||||
|
||||
# Keep running so user can see the image
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCleaning up...")
|
||||
await hal.cleanup()
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Test hardware display')
|
||||
parser.add_argument('--vcom', type=float, default=-1.65,
|
||||
help='VCOM voltage (check your display label, default: -1.65)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
asyncio.run(main(args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nTest interrupted")
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to render the library view and display it on hardware.
|
||||
Combines library rendering with hardware display testing.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
|
||||
|
||||
async def main(args):
|
||||
"""Test library rendering on hardware display."""
|
||||
|
||||
library_path = args.library_path
|
||||
output_path = "library_render_test.png"
|
||||
vcom = args.vcom
|
||||
|
||||
print("=" * 60)
|
||||
print("Library Rendering on Hardware Test")
|
||||
print("=" * 60)
|
||||
print(f"Library path: {library_path}")
|
||||
print(f"VCOM: {vcom}V")
|
||||
print(f"Output file: {output_path}")
|
||||
print()
|
||||
|
||||
# Step 1: Create library manager and scan
|
||||
print("Step 1: Creating library manager...")
|
||||
library = LibraryManager(
|
||||
library_path=library_path,
|
||||
page_size=(1872, 1404)
|
||||
)
|
||||
|
||||
print("Scanning library...")
|
||||
library.scan_library()
|
||||
|
||||
print()
|
||||
print(f"Found {len(library.books)} books:")
|
||||
for i, book in enumerate(library.books[:10], 1): # Show first 10
|
||||
print(f" {i}. {book['title']} by {book['author']}")
|
||||
if len(library.books) > 10:
|
||||
print(f" ... and {len(library.books) - 10} more")
|
||||
print()
|
||||
|
||||
# Step 2: Create and render library table
|
||||
print("Step 2: Creating library table (page 1)...")
|
||||
table = library.create_library_table(page=0)
|
||||
print(f"✓ Library table created: {table is not None}")
|
||||
if table:
|
||||
print(f" Table has {len(table.rows)} rows")
|
||||
print()
|
||||
|
||||
print("Rendering library view...")
|
||||
image = library.render_library()
|
||||
|
||||
if not image:
|
||||
print("ERROR: No image rendered!")
|
||||
print(f" library.library_table = {library.library_table}")
|
||||
print(f" library.rendered_page = {library.rendered_page}")
|
||||
return 1
|
||||
|
||||
print(f"✓ Library rendered: {image.size} {image.mode}")
|
||||
|
||||
# Check if image is not blank
|
||||
import numpy as np
|
||||
img_array = np.array(image)
|
||||
unique_colors = len(np.unique(img_array.reshape(-1, img_array.shape[-1]), axis=0))
|
||||
print(f" Image has {unique_colors} unique colors (blank would be ~1)")
|
||||
|
||||
# Sample some pixels to see what we have
|
||||
print(f" Sample pixels:")
|
||||
print(f" Top-left corner: {image.getpixel((10, 10))}")
|
||||
print(f" Center: {image.getpixel((936, 702))}")
|
||||
print(f" Bottom-right: {image.getpixel((1860, 1390))}")
|
||||
print()
|
||||
|
||||
# Save for reference
|
||||
print(f"Saving to {output_path}...")
|
||||
image.save(output_path)
|
||||
print(f"✓ Saved to {output_path}")
|
||||
print()
|
||||
|
||||
# Step 3: Initialize hardware HAL
|
||||
print("Step 3: Initializing hardware HAL...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
vcom=vcom,
|
||||
config_file="hardware_config.json"
|
||||
)
|
||||
|
||||
print("Initializing hardware components...")
|
||||
await hal.initialize()
|
||||
print("✓ Hardware initialized")
|
||||
print()
|
||||
|
||||
# Step 4: Display on e-ink screen
|
||||
print("Step 4: Displaying library on e-ink screen...")
|
||||
print("(This may take a few seconds for the full refresh)")
|
||||
print(f"Converting image from {image.mode} to grayscale for e-ink...")
|
||||
|
||||
# Convert to grayscale (L mode) for e-ink display
|
||||
if image.mode == 'RGBA':
|
||||
# Convert RGBA to RGB first (flatten alpha)
|
||||
rgb_image = Image.new('RGB', image.size, (255, 255, 255))
|
||||
rgb_image.paste(image, mask=image.split()[3]) # Use alpha as mask
|
||||
image_gray = rgb_image.convert('L')
|
||||
else:
|
||||
image_gray = image.convert('L')
|
||||
|
||||
print(f"Image converted to {image_gray.mode} mode")
|
||||
await hal.show_image(image_gray)
|
||||
print("✓ Image displayed!")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("SUCCESS!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("The library view should now be visible on your e-ink screen.")
|
||||
print()
|
||||
print(f"You can also view the saved PNG: {output_path}")
|
||||
print()
|
||||
print("Press Ctrl+C to exit")
|
||||
print()
|
||||
|
||||
# Keep running so user can see the image
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCleaning up...")
|
||||
await hal.cleanup()
|
||||
print("Done!")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Test library rendering on hardware display'
|
||||
)
|
||||
parser.add_argument(
|
||||
'library_path',
|
||||
nargs='?',
|
||||
default='tests/data/library-epub',
|
||||
help='Path to library directory (default: tests/data/library-epub)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--vcom',
|
||||
type=float,
|
||||
default=-1.65,
|
||||
help='VCOM voltage (check your display label, default: -1.65)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
exit_code = asyncio.run(main(args))
|
||||
sys.exit(exit_code or 0)
|
||||
except KeyboardInterrupt:
|
||||
print("\nTest interrupted")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to render the library view and save it as PNG.
|
||||
This helps verify the rendering is working correctly.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.library import LibraryManager
|
||||
|
||||
|
||||
def main():
|
||||
"""Test library rendering and save as PNG."""
|
||||
|
||||
library_path = "tests/data/library-epub"
|
||||
output_path = "library_render_test.png"
|
||||
|
||||
print("=" * 60)
|
||||
print("Library Rendering Test")
|
||||
print("=" * 60)
|
||||
print(f"Library path: {library_path}")
|
||||
print(f"Output file: {output_path}")
|
||||
print()
|
||||
|
||||
# Create library manager
|
||||
print("Creating library manager...")
|
||||
library = LibraryManager(
|
||||
library_path=library_path,
|
||||
page_size=(1872, 1404)
|
||||
)
|
||||
|
||||
# Scan library
|
||||
print("Scanning library...")
|
||||
library.scan_library()
|
||||
|
||||
print()
|
||||
print(f"Found {len(library.books)} books:")
|
||||
for book in library.books:
|
||||
print(f" - {book['title']} by {book['author']}")
|
||||
print()
|
||||
|
||||
# Create library table (renders the first page)
|
||||
print("Creating library table (page 1)...")
|
||||
library.create_library_table(page=0)
|
||||
|
||||
# Render the table
|
||||
print("Rendering library view...")
|
||||
image = library.render_library()
|
||||
|
||||
if image:
|
||||
print(f"Image size: {image.size}")
|
||||
print(f"Image mode: {image.mode}")
|
||||
|
||||
# Save as PNG
|
||||
print(f"Saving to {output_path}...")
|
||||
image.save(output_path)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUCCESS!")
|
||||
print("=" * 60)
|
||||
print(f"Library view saved to: {output_path}")
|
||||
print()
|
||||
print("You can view the image with:")
|
||||
print(f" xdg-open {output_path} # Linux")
|
||||
print(f" open {output_path} # macOS")
|
||||
print()
|
||||
|
||||
return 0
|
||||
else:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("ERROR: No image rendered")
|
||||
print("=" * 60)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simplest possible test - half black, half white screen.
|
||||
This verifies the display can show black pixels.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
|
||||
|
||||
def create_simple_test():
|
||||
"""Create a simple half-black, half-white test image."""
|
||||
print("Creating simple test pattern...")
|
||||
print("Left half: BLACK")
|
||||
print("Right half: WHITE")
|
||||
|
||||
width, height = 1872, 1404
|
||||
|
||||
# Create grayscale image (L mode = 8-bit grayscale)
|
||||
img = Image.new('L', (width, height), color=255) # Start with white
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw left half black
|
||||
draw.rectangle(
|
||||
[(0, 0), (width // 2, height)],
|
||||
fill=0 # 0 = black
|
||||
)
|
||||
|
||||
print(f"Test image created: {width}x{height} grayscale")
|
||||
print(f" Left half ({width//2}px): BLACK (0)")
|
||||
print(f" Right half ({width//2}px): WHITE (255)")
|
||||
|
||||
return img
|
||||
|
||||
|
||||
async def main(args):
|
||||
"""Test hardware display with simple pattern."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Simple Display Test - Half Black, Half White")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
vcom = args.vcom
|
||||
print(f"Using VCOM: {vcom}V")
|
||||
print()
|
||||
|
||||
# Create simple test image
|
||||
test_image = create_simple_test()
|
||||
|
||||
# Save test image for reference
|
||||
output_file = "simple_test.png"
|
||||
test_image.save(output_file)
|
||||
print(f"Test pattern saved to: {output_file}")
|
||||
print()
|
||||
|
||||
# Initialize hardware HAL
|
||||
print("Initializing hardware HAL...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
vcom=vcom,
|
||||
config_file="hardware_config.json"
|
||||
)
|
||||
|
||||
print("Initializing hardware components...")
|
||||
await hal.initialize()
|
||||
print("✓ Hardware initialized")
|
||||
print()
|
||||
|
||||
# Display the test image
|
||||
print("Displaying test pattern on e-ink screen...")
|
||||
print("(This may take a few seconds for the full refresh)")
|
||||
await hal.show_image(test_image)
|
||||
print("✓ Image displayed!")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("CHECK YOUR SCREEN:")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("You should see:")
|
||||
print(" • LEFT HALF: BLACK")
|
||||
print(" • RIGHT HALF: WHITE")
|
||||
print()
|
||||
print("If you see this, the display is working!")
|
||||
print("If the screen is all white, there may be a driver issue.")
|
||||
print()
|
||||
print("Press Ctrl+C to exit")
|
||||
print()
|
||||
|
||||
# Keep running so user can see the image
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCleaning up...")
|
||||
await hal.cleanup()
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Simple display test')
|
||||
parser.add_argument('--vcom', type=float, default=-1.65,
|
||||
help='VCOM voltage (check your display label, default: -1.65)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
asyncio.run(main(args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nTest interrupted")
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -18,8 +18,7 @@ This is useful for:
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
|
||||
from dreader.application import EbookReader
|
||||
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
||||
from dreader import EbookReader, TouchEvent, GestureType
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"_description": "Hardware configuration for DReader e-ink device",
|
||||
"_note": "This config matches the actual hardware: GPIO 22=prev, GPIO 27=next, GPIO 21=power, I2C on GPIO 2/3",
|
||||
|
||||
"display": {
|
||||
"width": 1872,
|
||||
"height": 1404,
|
||||
"vcom": -1.7,
|
||||
"spi_hz": 24000000,
|
||||
"rotate": "CW",
|
||||
"auto_sleep": true
|
||||
},
|
||||
|
||||
"gpio_buttons": {
|
||||
"enabled": true,
|
||||
"pull_up": true,
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": [
|
||||
{
|
||||
"name": "prev_page",
|
||||
"gpio": 22,
|
||||
"gesture": "swipe_right",
|
||||
"description": "Previous page button"
|
||||
},
|
||||
{
|
||||
"name": "next_page",
|
||||
"gpio": 27,
|
||||
"gesture": "swipe_left",
|
||||
"description": "Next page button"
|
||||
},
|
||||
{
|
||||
"name": "power_off",
|
||||
"gpio": 21,
|
||||
"gesture": "long_press",
|
||||
"description": "Power off button (long press to shutdown)",
|
||||
"pull_up": false,
|
||||
"comment": "Active high button - pulls HIGH when pressed (unlike prev/next which pull LOW)"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"accelerometer": {
|
||||
"enabled": true,
|
||||
"tilt_enabled": false,
|
||||
"orientation_enabled": true,
|
||||
"calibration_file": "accelerometer_config.json"
|
||||
},
|
||||
|
||||
"rtc": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"power_monitor": {
|
||||
"enabled": true,
|
||||
"shunt_ohms": 0.1,
|
||||
"battery_capacity_mah": 3000,
|
||||
"low_battery_threshold": 20.0,
|
||||
"show_battery_interval": 100
|
||||
},
|
||||
|
||||
"application": {
|
||||
"library_path": "/home/pi/Books",
|
||||
"auto_save_interval": 60,
|
||||
"force_library_mode": false,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# Installation script for dreader-hal hardware drivers
|
||||
#
|
||||
# This script installs all the external driver dependencies needed
|
||||
# for running DReader on e-ink hardware.
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "================================"
|
||||
echo "DReader Hardware Driver Installer"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Check if we're in a virtual environment
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "⚠️ Warning: No virtual environment detected!"
|
||||
echo "It's recommended to activate your virtual environment first:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Initialize submodules if not already done
|
||||
echo "Step 1: Initializing git submodules..."
|
||||
git submodule update --init --recursive
|
||||
echo "✓ Submodules initialized"
|
||||
echo ""
|
||||
|
||||
# Install dreader-hal main package
|
||||
echo "Step 2: Installing dreader-hal..."
|
||||
pip install -e external/dreader-hal
|
||||
echo "✓ dreader-hal installed"
|
||||
echo ""
|
||||
|
||||
# Install external drivers
|
||||
echo "Step 3: Installing external driver libraries..."
|
||||
|
||||
echo " - Installing IT8951 (E-ink display driver)..."
|
||||
pip install -e external/dreader-hal/external/IT8951
|
||||
|
||||
echo " - Installing PyBMA400 (Accelerometer)..."
|
||||
pip install -e external/dreader-hal/external/PyBMA400
|
||||
|
||||
echo " - Installing PyFTtxx6 (Touch panel)..."
|
||||
pip install -e external/dreader-hal/external/PyFTtxx6/pyft5xx6
|
||||
|
||||
echo " - Installing PyPCF8523 (RTC)..."
|
||||
pip install -e external/dreader-hal/external/PyPCF8523
|
||||
|
||||
echo " - Installing pi_ina219 (Power monitor)..."
|
||||
pip install -e external/dreader-hal/external/pi_ina219
|
||||
|
||||
echo "✓ All drivers installed"
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "================================"
|
||||
echo "Installation Complete!"
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "Installed packages:"
|
||||
echo " ✓ dreader-hal (main HAL library)"
|
||||
echo " ✓ IT8951 (e-ink display)"
|
||||
echo " ✓ PyBMA400 (accelerometer)"
|
||||
echo " ✓ PyFTtxx6 (touch panel)"
|
||||
echo " ✓ PyPCF8523 (RTC)"
|
||||
echo " ✓ pi_ina219 (power monitor)"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Wire up your hardware according to docs/HARDWARE.md"
|
||||
echo " 2. Check your display's VCOM voltage (on label)"
|
||||
echo " 3. Run: python examples/run_on_hardware.py /path/to/books --vcom YOUR_VCOM"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " python examples/run_on_hardware.py ~/Books --vcom -2.06"
|
||||
echo ""
|
||||
@@ -27,7 +27,7 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"pyweblayout @ git+https://gitea.tourolle.paris/dtourolle/pyWebLayout",
|
||||
"pyweblayout @ git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master",
|
||||
"Pillow>=9.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DReader E-Book Reader - Main Entry Point
|
||||
|
||||
This script launches the DReader application with a Pygame-based
|
||||
desktop HAL for testing and development.
|
||||
|
||||
Usage:
|
||||
python run_dreader.py [library_path]
|
||||
python run_dreader.py ~/Books
|
||||
python run_dreader.py tests/data/library-epub
|
||||
|
||||
Options:
|
||||
--width WIDTH Window width (default: 800)
|
||||
--height HEIGHT Window height (default: 1200)
|
||||
--fullscreen Run in fullscreen mode
|
||||
--log-level LEVEL Logging level: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
||||
|
||||
Examples:
|
||||
# Run with default library path
|
||||
python run_dreader.py
|
||||
|
||||
# Run with custom library
|
||||
python run_dreader.py ~/Documents/Books
|
||||
|
||||
# Run in fullscreen
|
||||
python run_dreader.py --fullscreen
|
||||
|
||||
# Run with debug logging
|
||||
python run_dreader.py --log-level DEBUG
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
from dreader.hal_pygame import PygameDisplayHAL
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="DReader E-Book Reader Application",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s # Use default library path
|
||||
%(prog)s ~/Books # Custom library path
|
||||
%(prog)s --width 1200 --height 1600 # Custom window size
|
||||
%(prog)s --fullscreen # Fullscreen mode
|
||||
%(prog)s --log-level DEBUG # Debug logging
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'library_path',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='Path to directory containing EPUB files (default: tests/data/library-epub)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--width',
|
||||
type=int,
|
||||
default=800,
|
||||
help='Window width in pixels (default: 800)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--height',
|
||||
type=int,
|
||||
default=1200,
|
||||
help='Window height in pixels (default: 1200)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--fullscreen',
|
||||
action='store_true',
|
||||
help='Run in fullscreen mode'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--library',
|
||||
action='store_true',
|
||||
help='Always start in library mode (ignore saved state)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
|
||||
default='INFO',
|
||||
help='Logging level (default: INFO)'
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
args = parse_args()
|
||||
|
||||
# Set up logging
|
||||
log_level = getattr(logging, args.log_level)
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Starting DReader E-Book Reader")
|
||||
|
||||
# Determine library path
|
||||
if args.library_path:
|
||||
library_path = Path(args.library_path).expanduser().resolve()
|
||||
else:
|
||||
# Default to test library
|
||||
library_path = Path(__file__).parent / "tests" / "data" / "library-epub"
|
||||
|
||||
# Verify library path exists
|
||||
if not library_path.exists():
|
||||
logger.error(f"Library path does not exist: {library_path}")
|
||||
print(f"\nError: Library directory not found: {library_path}")
|
||||
print("\nPlease provide a valid path to a directory containing EPUB files.")
|
||||
print("Example:")
|
||||
print(f" python {sys.argv[0]} ~/Documents/Books")
|
||||
sys.exit(1)
|
||||
|
||||
if not library_path.is_dir():
|
||||
logger.error(f"Library path is not a directory: {library_path}")
|
||||
print(f"\nError: Not a directory: {library_path}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Library path: {library_path}")
|
||||
logger.info(f"Window size: {args.width}x{args.height}")
|
||||
logger.info(f"Fullscreen: {args.fullscreen}")
|
||||
logger.info(f"Force library mode: {args.library}")
|
||||
|
||||
# Create HAL
|
||||
try:
|
||||
hal = PygameDisplayHAL(
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
fullscreen=args.fullscreen
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(f"Failed to initialize Pygame HAL: {e}")
|
||||
print(f"\nError: {e}")
|
||||
print("\nTo install Pygame, run:")
|
||||
print(" pip install pygame")
|
||||
sys.exit(1)
|
||||
|
||||
# Create application config
|
||||
config = AppConfig(
|
||||
display_hal=hal,
|
||||
library_path=str(library_path),
|
||||
page_size=(args.width, args.height),
|
||||
force_library_mode=args.library,
|
||||
log_level=log_level
|
||||
)
|
||||
|
||||
# Create application
|
||||
app = DReaderApplication(config)
|
||||
|
||||
# Run event loop
|
||||
try:
|
||||
logger.info("Starting event loop")
|
||||
asyncio.run(hal.run_event_loop(app))
|
||||
logger.info("Application exited normally")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
print("\nShutting down...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Application error: {e}", exc_info=True)
|
||||
print(f"\nError: {e}")
|
||||
print("\nFor more details, run with --log-level DEBUG")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script to visualize interactive elements in overlays.
|
||||
Shows where clickable links are located.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||
|
||||
from dreader.application import EbookReader
|
||||
from dreader.overlays.settings import SettingsOverlay
|
||||
from dreader.overlays.navigation import NavigationOverlay
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def find_all_links(overlay_reader, panel_width, panel_height):
|
||||
"""Scan overlay to find all interactive link positions."""
|
||||
link_positions = {}
|
||||
|
||||
if not overlay_reader or not overlay_reader.manager:
|
||||
print("No overlay reader available")
|
||||
return link_positions
|
||||
|
||||
page = overlay_reader.manager.get_current_page()
|
||||
if not page:
|
||||
print("No page available")
|
||||
return link_positions
|
||||
|
||||
print(f"Scanning {panel_width}x{panel_height} overlay for interactive elements...")
|
||||
|
||||
# Scan with moderate granularity (every 5 pixels)
|
||||
for y in range(0, panel_height, 5):
|
||||
for x in range(0, panel_width, 5):
|
||||
result = page.query_point((x, y))
|
||||
if result and result.link_target:
|
||||
if result.link_target not in link_positions:
|
||||
link_positions[result.link_target] = {
|
||||
'first_pos': (x, y),
|
||||
'bounds': result.bounds,
|
||||
'text': result.text
|
||||
}
|
||||
|
||||
return link_positions
|
||||
|
||||
|
||||
def visualize_settings_overlay():
|
||||
"""Visualize interactive elements in settings overlay."""
|
||||
print("\n" + "="*70)
|
||||
print("SETTINGS OVERLAY - Interactive Element Map")
|
||||
print("="*70)
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load a test book
|
||||
test_book = Path(__file__).parents[2] / "tests" / "data" / "library-epub" / "pg11-images-3.epub"
|
||||
if not test_book.exists():
|
||||
print(f"Test book not found: {test_book}")
|
||||
return
|
||||
|
||||
reader.load_epub(str(test_book))
|
||||
|
||||
# Create settings overlay
|
||||
settings_overlay = SettingsOverlay(reader)
|
||||
base_page = reader.get_current_page()
|
||||
|
||||
# Open overlay
|
||||
overlay_image = settings_overlay.open(
|
||||
base_page,
|
||||
font_scale=1.0,
|
||||
line_spacing=5,
|
||||
inter_block_spacing=15,
|
||||
word_spacing=0
|
||||
)
|
||||
|
||||
# Find all interactive elements
|
||||
panel_width = 480 # 60% of 800
|
||||
panel_height = 840 # 70% of 1200
|
||||
|
||||
link_positions = find_all_links(
|
||||
settings_overlay._overlay_reader,
|
||||
panel_width,
|
||||
panel_height
|
||||
)
|
||||
|
||||
print(f"\nFound {len(link_positions)} interactive elements:")
|
||||
for link_target, info in sorted(link_positions.items()):
|
||||
x, y = info['first_pos']
|
||||
bounds = info['bounds']
|
||||
text = info['text']
|
||||
print(f" {link_target:30s} at ({x:3d}, {y:3d}) - \"{text}\"")
|
||||
print(f" Bounds: {bounds}")
|
||||
|
||||
# Create visualization
|
||||
print("\nCreating visualization...")
|
||||
|
||||
# Get just the overlay panel (not the composited image)
|
||||
overlay_panel = settings_overlay._cached_overlay_image.copy()
|
||||
draw = ImageDraw.Draw(overlay_panel)
|
||||
|
||||
# Draw markers on each interactive element
|
||||
for link_target, info in link_positions.items():
|
||||
x, y = info['first_pos']
|
||||
|
||||
# Draw red circle at first detected position
|
||||
radius = 8
|
||||
draw.ellipse(
|
||||
[x - radius, y - radius, x + radius, y + radius],
|
||||
outline=(255, 0, 0),
|
||||
width=2
|
||||
)
|
||||
|
||||
# Draw crosshair
|
||||
draw.line([(x - 15, y), (x + 15, y)], fill=(255, 0, 0), width=1)
|
||||
draw.line([(x, y - 15), (x, y + 15)], fill=(255, 0, 0), width=1)
|
||||
|
||||
# Save visualization
|
||||
output_path = Path(__file__).parent / "overlay_links_debug.png"
|
||||
overlay_panel.save(output_path)
|
||||
print(f"\nVisualization saved to: {output_path}")
|
||||
print("Red circles show clickable link positions")
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
def visualize_navigation_overlay():
|
||||
"""Visualize interactive elements in navigation overlay."""
|
||||
print("\n" + "="*70)
|
||||
print("NAVIGATION OVERLAY - Interactive Element Map")
|
||||
print("="*70)
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load a test book
|
||||
test_book = Path(__file__).parents[2] / "tests" / "data" / "library-epub" / "pg11-images-3.epub"
|
||||
if not test_book.exists():
|
||||
print(f"Test book not found: {test_book}")
|
||||
return
|
||||
|
||||
reader.load_epub(str(test_book))
|
||||
|
||||
# Create navigation overlay
|
||||
nav_overlay = NavigationOverlay(reader)
|
||||
base_page = reader.get_current_page()
|
||||
|
||||
# Get chapters
|
||||
chapters = reader.get_chapters()
|
||||
|
||||
# Open overlay
|
||||
overlay_image = nav_overlay.open(
|
||||
base_page,
|
||||
chapters=chapters,
|
||||
bookmarks=[],
|
||||
active_tab="contents"
|
||||
)
|
||||
|
||||
# Find all interactive elements
|
||||
panel_width = 480 # 60% of 800
|
||||
panel_height = 840 # 70% of 1200
|
||||
|
||||
link_positions = find_all_links(
|
||||
nav_overlay._overlay_reader,
|
||||
panel_width,
|
||||
panel_height
|
||||
)
|
||||
|
||||
print(f"\nFound {len(link_positions)} interactive elements:")
|
||||
for link_target, info in sorted(link_positions.items()):
|
||||
x, y = info['first_pos']
|
||||
text = info['text']
|
||||
print(f" {link_target:30s} at ({x:3d}, {y:3d}) - \"{text}\"")
|
||||
|
||||
# Create visualization
|
||||
print("\nCreating visualization...")
|
||||
|
||||
# Get just the overlay panel
|
||||
overlay_panel = nav_overlay._cached_overlay_image.copy()
|
||||
draw = ImageDraw.Draw(overlay_panel)
|
||||
|
||||
# Draw markers on each interactive element
|
||||
for link_target, info in link_positions.items():
|
||||
x, y = info['first_pos']
|
||||
|
||||
# Draw green circle
|
||||
radius = 8
|
||||
draw.ellipse(
|
||||
[x - radius, y - radius, x + radius, y + radius],
|
||||
outline=(0, 255, 0),
|
||||
width=2
|
||||
)
|
||||
|
||||
# Save visualization
|
||||
output_path = Path(__file__).parent / "nav_overlay_links_debug.png"
|
||||
overlay_panel.save(output_path)
|
||||
print(f"\nVisualization saved to: {output_path}")
|
||||
print("Green circles show clickable link positions")
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
visualize_settings_overlay()
|
||||
visualize_navigation_overlay()
|
||||
print("\n" + "="*70)
|
||||
print("Debug complete! Check the generated PNG files.")
|
||||
print("="*70)
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug previous_page issue.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||
|
||||
from dreader.application import EbookReader
|
||||
|
||||
def debug_previous():
|
||||
"""Debug previous_page functionality."""
|
||||
|
||||
epub_path = Path("tests/data/library-epub/pg11-images-3.epub")
|
||||
|
||||
print("=" * 70)
|
||||
print("Debug Previous Page")
|
||||
print("=" * 70)
|
||||
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
reader.load_epub(str(epub_path))
|
||||
|
||||
print(f"\nLoaded: {reader.book_title}")
|
||||
print(f"Manager type: {type(reader.manager)}")
|
||||
print(f"Manager has previous_page: {hasattr(reader.manager, 'previous_page')}")
|
||||
|
||||
# Check manager's state
|
||||
if reader.manager:
|
||||
print(f"\nManager state:")
|
||||
print(f" current_position: {reader.manager.current_position}")
|
||||
if hasattr(reader.manager, 'page_buffer'):
|
||||
print(f" page_buffer length: {len(reader.manager.page_buffer)}")
|
||||
if hasattr(reader.manager, 'buffer'):
|
||||
print(f" buffer: {reader.manager.buffer}")
|
||||
|
||||
# Try going forward first
|
||||
print("\n" + "-" * 70)
|
||||
print("Going forward 3 pages...")
|
||||
print("-" * 70)
|
||||
|
||||
for i in range(3):
|
||||
page = reader.next_page()
|
||||
if page:
|
||||
print(f" Forward {i+1}: position = {reader.manager.current_position}")
|
||||
else:
|
||||
print(f" Forward {i+1}: FAILED")
|
||||
|
||||
if reader.manager:
|
||||
print(f"\nAfter forward navigation:")
|
||||
print(f" current_position: {reader.manager.current_position}")
|
||||
if hasattr(reader.manager, 'page_buffer'):
|
||||
print(f" page_buffer length: {len(reader.manager.page_buffer)}")
|
||||
if len(reader.manager.page_buffer) > 0:
|
||||
print(f" page_buffer[0]: {reader.manager.page_buffer[0].position if hasattr(reader.manager.page_buffer[0], 'position') else 'N/A'}")
|
||||
|
||||
# Now try going backward
|
||||
print("\n" + "-" * 70)
|
||||
print("Trying to go backward...")
|
||||
print("-" * 70)
|
||||
|
||||
# Try calling previous_page directly on manager
|
||||
if reader.manager:
|
||||
print("\nCalling manager.previous_page() directly...")
|
||||
result = reader.manager.previous_page()
|
||||
print(f" Result: {type(result) if result else None}")
|
||||
if result:
|
||||
print(f" Result has render(): {hasattr(result, 'render')}")
|
||||
print(f" Position after: {reader.manager.current_position}")
|
||||
else:
|
||||
print(f" Result is None")
|
||||
print(f" Position still: {reader.manager.current_position}")
|
||||
|
||||
# Try via reader.previous_page()
|
||||
print("\nCalling reader.previous_page()...")
|
||||
page = reader.previous_page()
|
||||
if page:
|
||||
print(f" SUCCESS: Got page {page.size}")
|
||||
print(f" Position: {reader.manager.current_position}")
|
||||
else:
|
||||
print(f" FAILED: Got None")
|
||||
print(f" Position: {reader.manager.current_position}")
|
||||
|
||||
reader.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_previous()
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to visualize library pagination.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from dreader import LibraryManager
|
||||
|
||||
def test_pagination():
|
||||
"""Test pagination with actual library"""
|
||||
library_path = Path(__file__).parents[2] / 'tests' / 'data' / 'library-epub'
|
||||
|
||||
# Create library manager (default books_per_page=6)
|
||||
library = LibraryManager(
|
||||
library_path=str(library_path),
|
||||
page_size=(800, 1200)
|
||||
)
|
||||
|
||||
# Scan library
|
||||
books = library.scan_library()
|
||||
print(f"\nFound {len(books)} books")
|
||||
print(f"Books per page: {library.books_per_page}")
|
||||
print(f"Total pages: {library.get_total_pages()}")
|
||||
|
||||
# Render all pages
|
||||
for page_num in range(library.get_total_pages()):
|
||||
library.set_page(page_num)
|
||||
print(f"\n=== Rendering Page {page_num + 1}/{library.get_total_pages()} ===")
|
||||
|
||||
library.create_library_table()
|
||||
img = library.render_library()
|
||||
|
||||
output_path = f'/tmp/library_pagination_page{page_num + 1}.png'
|
||||
img.save(output_path)
|
||||
print(f"Saved to {output_path}")
|
||||
|
||||
# Show which books are on this page
|
||||
start_idx = page_num * library.books_per_page
|
||||
end_idx = min(start_idx + library.books_per_page, len(books))
|
||||
page_books = books[start_idx:end_idx]
|
||||
print(f"Books on this page ({len(page_books)}):")
|
||||
for book in page_books:
|
||||
print(f" - {book['title']} by {book['author']}")
|
||||
|
||||
# Cleanup
|
||||
library.cleanup()
|
||||
print("\nPagination test complete!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_pagination()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Update pyWebLayout to the latest version from git
|
||||
#
|
||||
# This script updates pyWebLayout to fix compatibility issues
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "================================"
|
||||
echo "pyWebLayout Update Script"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Check if we're in a virtual environment
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "⚠️ Warning: No virtual environment detected!"
|
||||
echo "It's recommended to activate your virtual environment first:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Updating pyWebLayout from git repository..."
|
||||
echo ""
|
||||
|
||||
# Upgrade pyWebLayout from the git repository
|
||||
pip install --upgrade git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Update Complete!"
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "pyWebLayout has been updated to the latest version."
|
||||
echo "You can now run the application normally."
|
||||
echo ""
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Raspberry Pi Setup Script for DReader Hardware.
|
||||
|
||||
This interactive script helps configure your DReader e-reader hardware by:
|
||||
1. Detecting connected hardware (I2C devices, SPI, etc.)
|
||||
2. Creating/editing hardware_config.json
|
||||
3. Installing required system packages
|
||||
4. Setting up permissions and services
|
||||
|
||||
Usage:
|
||||
sudo python3 setup_rpi.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# Check if running on Raspberry Pi
|
||||
try:
|
||||
with open('/proc/device-tree/model', 'r') as f:
|
||||
model = f.read()
|
||||
if 'Raspberry Pi' not in model:
|
||||
print("⚠️ Warning: This doesn't appear to be a Raspberry Pi")
|
||||
print(f" Detected: {model.strip()}")
|
||||
response = input("Continue anyway? (y/N): ")
|
||||
if response.lower() != 'y':
|
||||
sys.exit(1)
|
||||
except:
|
||||
print("⚠️ Warning: Could not detect Raspberry Pi")
|
||||
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
HEADER = '\033[95m'
|
||||
BLUE = '\033[94m'
|
||||
CYAN = '\033[96m'
|
||||
GREEN = '\033[92m'
|
||||
YELLOW = '\033[93m'
|
||||
RED = '\033[91m'
|
||||
END = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
|
||||
def print_header(text: str):
|
||||
"""Print a header."""
|
||||
print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}")
|
||||
print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.END}")
|
||||
print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}\n")
|
||||
|
||||
|
||||
def print_step(number: int, text: str):
|
||||
"""Print a step number."""
|
||||
print(f"\n{Colors.BOLD}{Colors.CYAN}Step {number}: {text}{Colors.END}")
|
||||
print(f"{Colors.CYAN}{'-'*70}{Colors.END}")
|
||||
|
||||
|
||||
def print_success(text: str):
|
||||
"""Print success message."""
|
||||
print(f"{Colors.GREEN}✓ {text}{Colors.END}")
|
||||
|
||||
|
||||
def print_warning(text: str):
|
||||
"""Print warning message."""
|
||||
print(f"{Colors.YELLOW}⚠ {text}{Colors.END}")
|
||||
|
||||
|
||||
def print_error(text: str):
|
||||
"""Print error message."""
|
||||
print(f"{Colors.RED}✗ {text}{Colors.END}")
|
||||
|
||||
|
||||
def run_command(cmd: str, check: bool = True) -> Tuple[int, str, str]:
|
||||
"""Run a shell command and return result."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.CalledProcessError as e:
|
||||
return e.returncode, e.stdout, e.stderr
|
||||
|
||||
|
||||
def check_interfaces() -> Dict[str, bool]:
|
||||
"""Check if I2C and SPI interfaces are enabled."""
|
||||
print("Checking system interfaces...")
|
||||
|
||||
interfaces = {
|
||||
'i2c': False,
|
||||
'spi': False,
|
||||
}
|
||||
|
||||
# Check I2C
|
||||
if os.path.exists('/dev/i2c-1'):
|
||||
interfaces['i2c'] = True
|
||||
print_success("I2C interface enabled")
|
||||
else:
|
||||
print_warning("I2C interface not enabled")
|
||||
|
||||
# Check SPI
|
||||
if os.path.exists('/dev/spidev0.0'):
|
||||
interfaces['spi'] = True
|
||||
print_success("SPI interface enabled")
|
||||
else:
|
||||
print_warning("SPI interface not enabled")
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def detect_i2c_devices() -> List[str]:
|
||||
"""Detect I2C devices."""
|
||||
print("\nScanning I2C bus...")
|
||||
|
||||
returncode, stdout, stderr = run_command("i2cdetect -y 1", check=False)
|
||||
|
||||
if returncode != 0:
|
||||
print_warning("Could not scan I2C bus (i2cdetect not found or no permission)")
|
||||
return []
|
||||
|
||||
# Parse i2cdetect output
|
||||
devices = []
|
||||
for line in stdout.split('\n'):
|
||||
if ':' in line:
|
||||
# Extract hex addresses
|
||||
parts = line.split(':')[1].split()
|
||||
for part in parts:
|
||||
if part != '--' and len(part) == 2:
|
||||
devices.append(f"0x{part}")
|
||||
|
||||
if devices:
|
||||
print_success(f"Found {len(devices)} I2C device(s): {', '.join(devices)}")
|
||||
|
||||
# Identify known devices
|
||||
device_map = {
|
||||
'0x38': 'FT5316 Touch Panel',
|
||||
'0x14': 'BMA400 Accelerometer',
|
||||
'0x15': 'BMA400 Accelerometer (alt)',
|
||||
'0x68': 'PCF8523 RTC',
|
||||
'0x40': 'INA219 Power Monitor',
|
||||
}
|
||||
|
||||
print("\nDetected devices:")
|
||||
for addr in devices:
|
||||
device_name = device_map.get(addr, 'Unknown device')
|
||||
print(f" {addr}: {device_name}")
|
||||
else:
|
||||
print_warning("No I2C devices detected")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def enable_interfaces():
|
||||
"""Enable I2C and SPI interfaces."""
|
||||
print("\nEnabling interfaces...")
|
||||
|
||||
# Use raspi-config to enable I2C and SPI
|
||||
print("Enabling I2C...")
|
||||
run_command("raspi-config nonint do_i2c 0", check=False)
|
||||
|
||||
print("Enabling SPI...")
|
||||
run_command("raspi-config nonint do_spi 0", check=False)
|
||||
|
||||
print_success("Interfaces enabled (reboot required to take effect)")
|
||||
|
||||
|
||||
def setup_permissions():
|
||||
"""Set up user permissions for GPIO, I2C, and SPI."""
|
||||
print("\nSetting up user permissions...")
|
||||
|
||||
user = os.environ.get('SUDO_USER', os.environ.get('USER'))
|
||||
|
||||
groups = ['gpio', 'i2c', 'spi']
|
||||
for group in groups:
|
||||
print(f"Adding user '{user}' to group '{group}'...")
|
||||
returncode, _, _ = run_command(f"usermod -a -G {group} {user}", check=False)
|
||||
|
||||
if returncode == 0:
|
||||
print_success(f"Added to {group} group")
|
||||
else:
|
||||
print_warning(f"Could not add to {group} group (may not exist)")
|
||||
|
||||
print_warning("You must log out and back in for group changes to take effect")
|
||||
|
||||
|
||||
def get_vcom_voltage() -> float:
|
||||
"""Prompt user for VCOM voltage."""
|
||||
print("\n" + Colors.BOLD + "VCOM Voltage Configuration" + Colors.END)
|
||||
print("="*70)
|
||||
print("Your e-ink display has a VCOM voltage printed on a label.")
|
||||
print("This is usually on the back of the display.")
|
||||
print("")
|
||||
print("Example labels:")
|
||||
print(" • VCOM = -2.06V")
|
||||
print(" • VCOM: -1.98V")
|
||||
print(" • -2.14V")
|
||||
print("")
|
||||
print(Colors.RED + Colors.BOLD + "⚠️ IMPORTANT: Using incorrect VCOM can damage your display!" + Colors.END)
|
||||
print("")
|
||||
|
||||
while True:
|
||||
vcom_str = input("Enter your display's VCOM voltage (e.g., -2.06): ").strip()
|
||||
|
||||
try:
|
||||
vcom = float(vcom_str)
|
||||
|
||||
if vcom > 0:
|
||||
print_warning("VCOM is usually negative. Did you forget the minus sign?")
|
||||
continue
|
||||
|
||||
if vcom < -3.0 or vcom > -1.0:
|
||||
print_warning(f"VCOM {vcom}V is unusual. Most displays are between -1.5V and -2.5V")
|
||||
confirm = input("Are you sure this is correct? (y/N): ")
|
||||
if confirm.lower() != 'y':
|
||||
continue
|
||||
|
||||
return vcom
|
||||
|
||||
except ValueError:
|
||||
print_error("Invalid voltage. Please enter a number (e.g., -2.06)")
|
||||
|
||||
|
||||
def configure_gpio_buttons() -> dict:
|
||||
"""Configure GPIO buttons interactively."""
|
||||
print("\n" + Colors.BOLD + "GPIO Button Configuration" + Colors.END)
|
||||
print("="*70)
|
||||
print("Configure physical buttons for navigation.")
|
||||
print("Buttons should be connected between GPIO pin and GND.")
|
||||
print("")
|
||||
|
||||
enable = input("Enable GPIO buttons? (Y/n): ").strip().lower()
|
||||
if enable == 'n':
|
||||
return {
|
||||
"enabled": False,
|
||||
"pull_up": True,
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": []
|
||||
}
|
||||
|
||||
buttons = []
|
||||
|
||||
# Common button configurations (based on actual hardware)
|
||||
button_presets = [
|
||||
("prev_page", "Previous Page", "swipe_right", 22),
|
||||
("next_page", "Next Page", "swipe_left", 27),
|
||||
("power_off", "Power Off", "long_press", 21),
|
||||
]
|
||||
|
||||
print("\nAvailable GPIOs (BCM numbering): 2-27 (avoid 2, 3 if using I2C)")
|
||||
print("")
|
||||
|
||||
for name, description, default_gesture, default_gpio in button_presets:
|
||||
print(f"\n{Colors.BOLD}{description} Button{Colors.END}")
|
||||
enable_btn = input(f" Enable {description} button? (Y/n): ").strip().lower()
|
||||
|
||||
if enable_btn == 'n':
|
||||
continue
|
||||
|
||||
# Get GPIO pin
|
||||
while True:
|
||||
gpio_str = input(f" GPIO pin (default {default_gpio}): ").strip()
|
||||
if not gpio_str:
|
||||
gpio = default_gpio
|
||||
break
|
||||
|
||||
try:
|
||||
gpio = int(gpio_str)
|
||||
if gpio < 2 or gpio > 27:
|
||||
print_error(" GPIO must be between 2 and 27")
|
||||
continue
|
||||
if gpio in [2, 3]:
|
||||
print_warning(" GPIO 2/3 are I2C pins (SDA/SCL)")
|
||||
confirm = input(" Use anyway? (y/N): ")
|
||||
if confirm.lower() != 'y':
|
||||
continue
|
||||
break
|
||||
except ValueError:
|
||||
print_error(" Invalid GPIO number")
|
||||
|
||||
# Add button
|
||||
buttons.append({
|
||||
"name": name,
|
||||
"gpio": gpio,
|
||||
"gesture": default_gesture,
|
||||
"description": description
|
||||
})
|
||||
|
||||
print_success(f" Configured: GPIO {gpio} -> {description}")
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"pull_up": True,
|
||||
"bounce_time_ms": 200,
|
||||
"buttons": buttons
|
||||
}
|
||||
|
||||
|
||||
def create_hardware_config(vcom: float, gpio_config: dict, i2c_devices: List[str]) -> dict:
|
||||
"""Create hardware configuration dictionary."""
|
||||
# Auto-detect which optional components are available
|
||||
has_touch = '0x38' in i2c_devices
|
||||
has_accel = '0x14' in i2c_devices or '0x15' in i2c_devices
|
||||
has_rtc = '0x68' in i2c_devices
|
||||
has_power = '0x40' in i2c_devices
|
||||
|
||||
config = {
|
||||
"_description": "Hardware configuration for DReader e-ink device",
|
||||
"_generated": "Generated by setup_rpi.py",
|
||||
|
||||
"display": {
|
||||
"width": 1872,
|
||||
"height": 1404,
|
||||
"vcom": vcom,
|
||||
"spi_hz": 24000000,
|
||||
"auto_sleep": True
|
||||
},
|
||||
|
||||
"gpio_buttons": gpio_config,
|
||||
|
||||
"accelerometer": {
|
||||
"enabled": has_accel,
|
||||
"tilt_enabled": False,
|
||||
"orientation_enabled": has_accel,
|
||||
"calibration_file": "accelerometer_config.json"
|
||||
},
|
||||
|
||||
"rtc": {
|
||||
"enabled": has_rtc
|
||||
},
|
||||
|
||||
"power_monitor": {
|
||||
"enabled": has_power,
|
||||
"shunt_ohms": 0.1,
|
||||
"battery_capacity_mah": 3000,
|
||||
"low_battery_threshold": 20.0,
|
||||
"show_battery_interval": 100
|
||||
},
|
||||
|
||||
"application": {
|
||||
"library_path": "/home/pi/Books",
|
||||
"auto_save_interval": 60,
|
||||
"force_library_mode": False,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
"""Main setup function."""
|
||||
print_header("DReader Raspberry Pi Hardware Setup")
|
||||
|
||||
# Check if running as root
|
||||
if os.geteuid() != 0:
|
||||
print_error("This script must be run with sudo")
|
||||
print("Usage: sudo python3 setup_rpi.py")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 1: Check interfaces
|
||||
print_step(1, "Checking System Interfaces")
|
||||
interfaces = check_interfaces()
|
||||
|
||||
if not all(interfaces.values()):
|
||||
print("\nSome interfaces are not enabled.")
|
||||
enable = input("Enable I2C and SPI now? (Y/n): ").strip().lower()
|
||||
|
||||
if enable != 'n':
|
||||
enable_interfaces()
|
||||
print_warning("Reboot required for interface changes to take effect")
|
||||
|
||||
# Step 2: Detect hardware
|
||||
print_step(2, "Detecting I2C Devices")
|
||||
i2c_devices = detect_i2c_devices()
|
||||
|
||||
if not i2c_devices:
|
||||
print_warning("No I2C devices detected. Check your wiring.")
|
||||
print("See docs/HARDWARE.md for wiring instructions.")
|
||||
|
||||
# Step 3: Set up permissions
|
||||
print_step(3, "Setting Up User Permissions")
|
||||
setup_permissions()
|
||||
|
||||
# Step 4: Configure VCOM
|
||||
print_step(4, "Display Configuration")
|
||||
vcom = get_vcom_voltage()
|
||||
print_success(f"VCOM voltage set to {vcom}V")
|
||||
|
||||
# Step 5: Configure GPIO buttons
|
||||
print_step(5, "GPIO Button Configuration")
|
||||
gpio_config = configure_gpio_buttons()
|
||||
|
||||
if gpio_config["enabled"]:
|
||||
print_success(f"Configured {len(gpio_config['buttons'])} button(s)")
|
||||
else:
|
||||
print("GPIO buttons disabled")
|
||||
|
||||
# Step 6: Generate configuration
|
||||
print_step(6, "Generating Configuration File")
|
||||
config = create_hardware_config(vcom, gpio_config, i2c_devices)
|
||||
|
||||
config_file = Path("hardware_config.json")
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print_success(f"Configuration saved to {config_file}")
|
||||
|
||||
# Step 7: Summary
|
||||
print_header("Setup Complete!")
|
||||
|
||||
print("Configuration summary:")
|
||||
print(f" • Display: {config['display']['width']}x{config['display']['height']}, VCOM={config['display']['vcom']}V")
|
||||
print(f" • GPIO Buttons: {'Enabled' if gpio_config['enabled'] else 'Disabled'}")
|
||||
if gpio_config['enabled']:
|
||||
for btn in gpio_config['buttons']:
|
||||
print(f" - {btn['description']}: GPIO {btn['gpio']}")
|
||||
print(f" • Accelerometer: {'Enabled' if config['accelerometer']['enabled'] else 'Disabled'}")
|
||||
print(f" • RTC: {'Enabled' if config['rtc']['enabled'] else 'Disabled'}")
|
||||
print(f" • Power Monitor: {'Enabled' if config['power_monitor']['enabled'] else 'Disabled'}")
|
||||
|
||||
print("\n" + Colors.BOLD + "Next Steps:" + Colors.END)
|
||||
print("1. Review and edit hardware_config.json if needed")
|
||||
print("2. Reboot if you enabled I2C/SPI: sudo reboot")
|
||||
print("3. Log out and back in for permission changes")
|
||||
print("4. Run: python examples/run_on_hardware_config.py")
|
||||
print("")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nSetup cancelled by user")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print_error(f"Setup failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Tests for accelerometer-based gesture detection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Test only the gesture types and math, not the full integration
|
||||
# to avoid dependencies on pyWebLayout
|
||||
|
||||
|
||||
class MockOrientationSensor:
|
||||
"""Mock BMA400 accelerometer for testing"""
|
||||
|
||||
def __init__(self):
|
||||
self.ax = 0.0
|
||||
self.ay = 0.0
|
||||
self.az = 9.8 # Standard gravity
|
||||
|
||||
async def get_acceleration(self):
|
||||
"""Return mock acceleration data"""
|
||||
return (self.ax, self.ay, self.az)
|
||||
|
||||
def set_acceleration(self, x, y, z):
|
||||
"""Set acceleration for testing"""
|
||||
self.ax = x
|
||||
self.ay = y
|
||||
self.az = z
|
||||
|
||||
|
||||
class MockHAL:
|
||||
"""Mock HAL for testing"""
|
||||
|
||||
def __init__(self):
|
||||
self.orientation = MockOrientationSensor()
|
||||
self.width = 800
|
||||
self.height = 1200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_hal():
|
||||
"""Create a mock HAL with accelerometer"""
|
||||
return MockHAL()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calibration_file(tmp_path):
|
||||
"""Create a temporary calibration file"""
|
||||
config = {
|
||||
"up_vector": {
|
||||
"x": 0.0,
|
||||
"y": 9.8,
|
||||
"z": 0.0
|
||||
},
|
||||
"tilt_threshold": 0.3, # ~17 degrees
|
||||
"debounce_time": 0.5
|
||||
}
|
||||
|
||||
config_path = tmp_path / "test_accel_config.json"
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f)
|
||||
|
||||
return str(config_path)
|
||||
|
||||
|
||||
def test_load_calibration_success(mock_hal, calibration_file):
|
||||
"""Test loading accelerometer calibration"""
|
||||
# Create a minimal HAL-like object
|
||||
class TestHAL:
|
||||
def __init__(self):
|
||||
self.width = 800
|
||||
self.height = 1200
|
||||
|
||||
test_hal = TestHAL()
|
||||
|
||||
# Manually call the load function
|
||||
result = load_accel_calibration(test_hal, calibration_file)
|
||||
|
||||
assert result is True
|
||||
assert hasattr(test_hal, 'accel_up_vector')
|
||||
assert test_hal.accel_up_vector == (0.0, 9.8, 0.0)
|
||||
assert test_hal.accel_tilt_threshold == 0.3
|
||||
assert test_hal.accel_debounce_time == 0.5
|
||||
|
||||
|
||||
def load_accel_calibration(hal, config_path):
|
||||
"""Helper function to load calibration (extracted from HardwareDisplayHAL)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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", {})
|
||||
hal.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
|
||||
|
||||
# Load thresholds
|
||||
hal.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
|
||||
hal.accel_debounce_time = config.get("debounce_time", 0.5)
|
||||
|
||||
# State tracking
|
||||
hal.accel_last_tilt_time = 0
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading accelerometer calibration: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_tilt_detection_forward():
|
||||
"""Test forward tilt detection"""
|
||||
# Setup: device is upright (y = 9.8), then tilt forward (z increases)
|
||||
# Calibrated up vector: (0, 9.8, 0)
|
||||
# Current gravity: (0, 6, 6) - tilted ~45 degrees forward
|
||||
|
||||
up_vector = (0.0, 9.8, 0.0)
|
||||
current_gravity = (0.0, 6.0, 6.0)
|
||||
|
||||
# Normalize vectors
|
||||
ux, uy, uz = up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
gx, gy, gz = current_gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Calculate tilt angle
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
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)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
# Should be approximately 45 degrees (0.785 radians)
|
||||
assert abs(tilt_angle - 0.785) < 0.1
|
||||
|
||||
# Direction: forward tilt should have positive perpendicular y component
|
||||
# Actually, when tilting forward, gravity vector rotates toward +z
|
||||
# The perpendicular component should reflect this
|
||||
|
||||
|
||||
def test_tilt_detection_backward():
|
||||
"""Test backward tilt detection"""
|
||||
# Setup: device is upright (y = 9.8), then tilt backward (z decreases, negative)
|
||||
# Calibrated up vector: (0, 9.8, 0)
|
||||
# Current gravity: (0, 6, -6) - tilted ~45 degrees backward
|
||||
|
||||
up_vector = (0.0, 9.8, 0.0)
|
||||
current_gravity = (0.0, 6.0, -6.0)
|
||||
|
||||
# Normalize vectors
|
||||
ux, uy, uz = up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
gx, gy, gz = current_gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Calculate tilt angle
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
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)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
# Should be approximately 45 degrees (0.785 radians)
|
||||
assert abs(tilt_angle - 0.785) < 0.1
|
||||
|
||||
|
||||
def test_no_tilt_when_upright():
|
||||
"""Test that no tilt is detected when device is upright"""
|
||||
# Setup: device is perfectly upright
|
||||
# Calibrated up vector: (0, 9.8, 0)
|
||||
# Current gravity: (0, 9.8, 0) - same as calibration
|
||||
|
||||
up_vector = (0.0, 9.8, 0.0)
|
||||
current_gravity = (0.0, 9.8, 0.0)
|
||||
|
||||
# Normalize vectors
|
||||
ux, uy, uz = up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
gx, gy, gz = current_gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Calculate tilt angle
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
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)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
# Should be approximately 0 degrees
|
||||
assert tilt_angle < 0.01
|
||||
|
||||
|
||||
def test_small_tilt_below_threshold():
|
||||
"""Test that small tilts below threshold are ignored"""
|
||||
# Setup: device is slightly tilted (10 degrees)
|
||||
# Calibrated up vector: (0, 9.8, 0)
|
||||
# Current gravity: small tilt
|
||||
|
||||
angle_rad = math.radians(10)
|
||||
up_vector = (0.0, 9.8, 0.0)
|
||||
current_gravity = (0.0, 9.8 * math.cos(angle_rad), 9.8 * math.sin(angle_rad))
|
||||
|
||||
# Normalize vectors
|
||||
ux, uy, uz = up_vector
|
||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
||||
|
||||
gx, gy, gz = current_gravity
|
||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
||||
|
||||
# Calculate tilt angle
|
||||
dot_up = gx * ux + gy * uy + gz * uz
|
||||
|
||||
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)
|
||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
||||
|
||||
# Should be approximately 10 degrees (0.174 radians)
|
||||
assert abs(tilt_angle - 0.174) < 0.01
|
||||
|
||||
# Should be below default threshold of 0.3 rad (~17 degrees)
|
||||
assert tilt_angle < 0.3
|
||||
|
||||
|
||||
def test_gesture_types_exist():
|
||||
"""Test that accelerometer gesture types are defined"""
|
||||
# Simple direct test - check that gesture strings are defined
|
||||
gestures = [
|
||||
"tap",
|
||||
"long_press",
|
||||
"swipe_left",
|
||||
"swipe_right",
|
||||
"swipe_up",
|
||||
"swipe_down",
|
||||
"pinch_in",
|
||||
"pinch_out",
|
||||
"drag_start",
|
||||
"drag_move",
|
||||
"drag_end",
|
||||
"tilt_forward", # Our new gestures
|
||||
"tilt_backward"
|
||||
]
|
||||
|
||||
# Verify the new gesture strings are valid
|
||||
assert "tilt_forward" in gestures
|
||||
assert "tilt_backward" in gestures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Regression test for backward navigation onto the cover page.
|
||||
|
||||
Going forward off the cover and then back again must return to block_index=0.
|
||||
|
||||
Previously it left current_position at the first content block (1) while flagging
|
||||
the cover as displayed, so the cover had two different internal representations.
|
||||
Since current_position is what gets persisted, saving while on the cover reopened
|
||||
the book past it. Fixed in pyWebLayout's EreaderLayoutManager.previous_page().
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from dreader.application import EbookReader
|
||||
|
||||
|
||||
class TestBackwardNavigationBug(unittest.TestCase):
|
||||
"""Minimal reproduction of backward navigation bug"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_minimal_backward_navigation_bug(self):
|
||||
"""
|
||||
MINIMAL CASE:
|
||||
|
||||
1. Start at block_index=0
|
||||
2. Go forward once (to block_index=1)
|
||||
3. Go backward once
|
||||
4. Should land back at block_index=0
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
# Starting position
|
||||
pos_start = reader.manager.current_position.copy()
|
||||
print(f"\n1. Starting at block_index={pos_start.block_index}")
|
||||
self.assertEqual(pos_start.block_index, 0, "Should start at block 0")
|
||||
|
||||
# Go forward
|
||||
reader.next_page()
|
||||
pos_forward = reader.manager.current_position.copy()
|
||||
print(f"2. After next_page(): block_index={pos_forward.block_index}")
|
||||
self.assertEqual(pos_forward.block_index, 1, "Should be at block 1")
|
||||
|
||||
# Go backward
|
||||
reader.previous_page()
|
||||
pos_final = reader.manager.current_position.copy()
|
||||
print(f"3. After previous_page(): block_index={pos_final.block_index}")
|
||||
|
||||
print(f"\nEXPECTED: block_index=0")
|
||||
print(f"ACTUAL: block_index={pos_final.block_index}")
|
||||
|
||||
self.assertEqual(
|
||||
pos_final.block_index,
|
||||
0,
|
||||
"Backward navigation from block 1 should return to block 0"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Detailed regression tests for backward navigation.
|
||||
|
||||
These cover the ways backward navigation has broken before:
|
||||
1. Complete failure (previous_page returns None)
|
||||
2. Imprecise positioning (lands on wrong block)
|
||||
3. Failure only after resuming from a saved position
|
||||
4. Failure during continuous navigation
|
||||
|
||||
See test_backward_nav_minimal.py for the minimal cover-page case.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from dreader.application import EbookReader
|
||||
|
||||
|
||||
class TestBackwardNavigationDetailed(unittest.TestCase):
|
||||
"""Detailed backward navigation tests"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_continuous_backward_navigation_no_resume(self):
|
||||
"""
|
||||
Test backward navigation without closing/resuming.
|
||||
This checks if the issue is specific to resume or general.
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
print("\n=== Test: Continuous backward navigation (no resume) ===")
|
||||
|
||||
# Record starting position
|
||||
pos0 = reader.manager.current_position.copy()
|
||||
print(f"Starting position: {pos0}")
|
||||
|
||||
# Go forward 5 pages, recording positions
|
||||
forward_positions = [pos0]
|
||||
for i in range(5):
|
||||
page = reader.next_page()
|
||||
if page is None:
|
||||
print(f"Reached end at page {i}")
|
||||
break
|
||||
pos = reader.manager.current_position.copy()
|
||||
forward_positions.append(pos)
|
||||
print(f"Forward page {i+1}: block_index={pos.block_index}")
|
||||
|
||||
num_forward = len(forward_positions) - 1
|
||||
print(f"\nNavigated forward {num_forward} pages")
|
||||
|
||||
# Now go backward the same number of times
|
||||
print("\n--- Going backward ---")
|
||||
backward_positions = []
|
||||
for i in range(num_forward):
|
||||
page = reader.previous_page()
|
||||
|
||||
if page is None:
|
||||
print(f"ERROR: previous_page() returned None at step {i+1}")
|
||||
self.fail(f"Backward navigation failed at step {i+1}")
|
||||
|
||||
pos = reader.manager.current_position.copy()
|
||||
backward_positions.append(pos)
|
||||
print(f"Backward step {i+1}: block_index={pos.block_index}")
|
||||
|
||||
# Check final position
|
||||
final_pos = reader.manager.current_position.copy()
|
||||
print(f"\nFinal position: {final_pos}")
|
||||
print(f"Expected (pos0): {pos0}")
|
||||
|
||||
if final_pos != pos0:
|
||||
print(f"WARNING: Position mismatch!")
|
||||
print(f" Expected block_index: {pos0.block_index}")
|
||||
print(f" Actual block_index: {final_pos.block_index}")
|
||||
print(f" Difference: {final_pos.block_index - pos0.block_index} blocks")
|
||||
|
||||
self.assertEqual(
|
||||
final_pos,
|
||||
pos0,
|
||||
f"After {num_forward} forward and {num_forward} backward, should be at start"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_backward_navigation_at_start(self):
|
||||
"""
|
||||
Test that previous_page() behaves correctly when at the start of the book.
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
print("\n=== Test: Backward navigation at start ===")
|
||||
|
||||
pos_start = reader.manager.current_position.copy()
|
||||
print(f"At start: {pos_start}")
|
||||
|
||||
# Try to go back from the very first page
|
||||
page = reader.previous_page()
|
||||
|
||||
print(f"previous_page() returned: {page is not None}")
|
||||
|
||||
pos_after = reader.manager.current_position.copy()
|
||||
print(f"Position after previous_page(): {pos_after}")
|
||||
|
||||
# Should either return None or stay at same position
|
||||
if page is not None:
|
||||
self.assertEqual(
|
||||
pos_after,
|
||||
pos_start,
|
||||
"If previous_page() returns a page at start, position should not change"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_alternating_navigation(self):
|
||||
"""
|
||||
Test alternating forward/backward navigation.
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
print("\n=== Test: Alternating forward/backward navigation ===")
|
||||
|
||||
pos0 = reader.manager.current_position.copy()
|
||||
print(f"Start: block_index={pos0.block_index}")
|
||||
|
||||
# Go forward, back, forward, back pattern
|
||||
operations = [
|
||||
("forward", 1),
|
||||
("backward", 1),
|
||||
("forward", 2),
|
||||
("backward", 1),
|
||||
("forward", 1),
|
||||
("backward", 2),
|
||||
]
|
||||
|
||||
for op, count in operations:
|
||||
for i in range(count):
|
||||
if op == "forward":
|
||||
page = reader.next_page()
|
||||
else:
|
||||
page = reader.previous_page()
|
||||
|
||||
self.assertIsNotNone(
|
||||
page,
|
||||
f"{op} navigation failed at iteration {i+1}"
|
||||
)
|
||||
|
||||
pos = reader.manager.current_position.copy()
|
||||
print(f"After {count}x {op}: block_index={pos.block_index}")
|
||||
|
||||
# We should end up at the starting position (net: +5 -4 = +1, then +1 -2 = -1, total = 0)
|
||||
# Actually: +1 -1 +2 -1 +1 -2 = 0
|
||||
final_pos = reader.manager.current_position.copy()
|
||||
print(f"\nFinal: block_index={final_pos.block_index}")
|
||||
print(f"Expected: block_index={pos0.block_index}")
|
||||
|
||||
self.assertEqual(
|
||||
final_pos,
|
||||
pos0,
|
||||
"Alternating navigation should return to start"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_backward_then_forward(self):
|
||||
"""
|
||||
Test that forward navigation works correctly after backward navigation.
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
print("\n=== Test: Backward then forward ===")
|
||||
|
||||
# Go forward 3 pages
|
||||
positions = [reader.manager.current_position.copy()]
|
||||
for i in range(3):
|
||||
reader.next_page()
|
||||
positions.append(reader.manager.current_position.copy())
|
||||
|
||||
print(f"Forward positions: {[p.block_index for p in positions]}")
|
||||
|
||||
# Go back 3 pages
|
||||
for i in range(3):
|
||||
reader.previous_page()
|
||||
|
||||
pos_after_back = reader.manager.current_position.copy()
|
||||
print(f"After going back: block_index={pos_after_back.block_index}")
|
||||
|
||||
# Now go forward 3 pages again
|
||||
for i in range(3):
|
||||
reader.next_page()
|
||||
|
||||
final_pos = reader.manager.current_position.copy()
|
||||
print(f"After going forward again: block_index={final_pos.block_index}")
|
||||
print(f"Expected: block_index={positions[3].block_index}")
|
||||
|
||||
self.assertEqual(
|
||||
final_pos,
|
||||
positions[3],
|
||||
"Forward after backward should reach same position"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Test backward navigation after resuming from a saved position.
|
||||
|
||||
This test specifically checks if backward navigation works correctly
|
||||
after opening an epub, navigating forward, closing it, then resuming
|
||||
and attempting to navigate backward.
|
||||
|
||||
This may reveal issues with pyWebLayout's backward navigation handling.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from dreader.application import EbookReader
|
||||
|
||||
|
||||
class TestBackwardNavigationAfterResume(unittest.TestCase):
|
||||
"""Test backward navigation behavior after resume"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
|
||||
"""
|
||||
Check if two PIL Images are pixel-perfect identical.
|
||||
"""
|
||||
if img1 is None or img2 is None:
|
||||
return False
|
||||
|
||||
if img1.size != img2.size:
|
||||
return False
|
||||
|
||||
arr1 = np.array(img1)
|
||||
arr2 = np.array(img2)
|
||||
|
||||
return np.array_equal(arr1, arr2)
|
||||
|
||||
def test_backward_navigation_after_resume(self):
|
||||
"""
|
||||
Test that backward navigation works after closing and resuming.
|
||||
|
||||
Steps:
|
||||
1. Open EPUB
|
||||
2. Navigate forward 3 pages
|
||||
3. Save positions and pages
|
||||
4. Close reader
|
||||
5. Open new reader (resume)
|
||||
6. Try to navigate backward
|
||||
7. Verify we can reach previous pages
|
||||
"""
|
||||
# Phase 1: Initial session - navigate forward
|
||||
reader1 = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0 # Disable buffering for consistent testing
|
||||
)
|
||||
|
||||
success = reader1.load_epub(self.epub_path)
|
||||
self.assertTrue(success, "Failed to load test EPUB")
|
||||
|
||||
# Capture initial page
|
||||
page0 = reader1.get_current_page()
|
||||
self.assertIsNotNone(page0, "Initial page should not be None")
|
||||
pos0 = reader1.manager.current_position.copy()
|
||||
|
||||
print(f"\nInitial position: {pos0}")
|
||||
|
||||
# Navigate forward 3 pages, capturing each page
|
||||
pages = [page0]
|
||||
positions = [pos0]
|
||||
|
||||
for i in range(3):
|
||||
page = reader1.next_page()
|
||||
self.assertIsNotNone(page, f"Page {i+1} should not be None")
|
||||
pages.append(page)
|
||||
positions.append(reader1.manager.current_position.copy())
|
||||
print(f"Forward page {i+1} position: {positions[-1]}")
|
||||
|
||||
# We should now be at page 3 (0-indexed)
|
||||
self.assertEqual(len(pages), 4, "Should have 4 pages total (0-3)")
|
||||
|
||||
# Save the current position before closing
|
||||
final_position = reader1.manager.current_position.copy()
|
||||
print(f"Final position before close: {final_position}")
|
||||
|
||||
# Close reader (this should save the position)
|
||||
reader1.close()
|
||||
|
||||
# Phase 2: Resume session - navigate backward
|
||||
reader2 = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
success = reader2.load_epub(self.epub_path)
|
||||
self.assertTrue(success, "Failed to load test EPUB on resume")
|
||||
|
||||
# Verify we resumed at the correct position
|
||||
resumed_position = reader2.manager.current_position.copy()
|
||||
print(f"Resumed at position: {resumed_position}")
|
||||
self.assertEqual(
|
||||
resumed_position,
|
||||
final_position,
|
||||
"Should resume at the last saved position"
|
||||
)
|
||||
|
||||
# Get the current page (should match page 3)
|
||||
resumed_page = reader2.get_current_page()
|
||||
self.assertIsNotNone(resumed_page, "Resumed page should not be None")
|
||||
|
||||
# Now try to navigate backward
|
||||
print("\nAttempting backward navigation...")
|
||||
|
||||
backward_pages = []
|
||||
backward_positions = []
|
||||
|
||||
# Try to go back 3 times
|
||||
for i in range(3):
|
||||
prev_page = reader2.previous_page()
|
||||
print(f"Backward step {i+1}: page={'Not None' if prev_page else 'None'}")
|
||||
|
||||
if prev_page is None:
|
||||
print(f"WARNING: previous_page() returned None at step {i+1}")
|
||||
# This is the bug we're testing for!
|
||||
self.fail(f"Backward navigation failed at step {i+1}: previous_page() returned None")
|
||||
|
||||
backward_pages.append(prev_page)
|
||||
backward_positions.append(reader2.manager.current_position.copy())
|
||||
print(f" Position after backward: {backward_positions[-1]}")
|
||||
|
||||
# We should have successfully gone back 3 pages
|
||||
self.assertEqual(len(backward_pages), 3, "Should have navigated back 3 pages")
|
||||
|
||||
# Verify final position matches original position
|
||||
final_backward_position = reader2.manager.current_position.copy()
|
||||
print(f"\nFinal position after backward navigation: {final_backward_position}")
|
||||
print(f"Original position (page 0): {pos0}")
|
||||
|
||||
self.assertEqual(
|
||||
final_backward_position,
|
||||
pos0,
|
||||
"After going forward 3 and back 3, should be at initial position"
|
||||
)
|
||||
|
||||
# Verify the page content matches
|
||||
final_page = reader2.get_current_page()
|
||||
self.assertTrue(
|
||||
self.compare_images(page0, final_page),
|
||||
"Final page should match initial page after forward/backward navigation"
|
||||
)
|
||||
|
||||
reader2.close()
|
||||
|
||||
print("\n✓ Test passed: Backward navigation works correctly after resume")
|
||||
|
||||
def test_backward_navigation_single_step(self):
|
||||
"""
|
||||
Simplified test: Open, go forward 1 page, close, resume, go back 1 page.
|
||||
This is a minimal reproduction case.
|
||||
"""
|
||||
# Session 1: Navigate forward one page
|
||||
reader1 = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
page0 = reader1.get_current_page()
|
||||
pos0 = reader1.manager.current_position.copy()
|
||||
|
||||
page1 = reader1.next_page()
|
||||
self.assertIsNotNone(page1, "Should be able to navigate forward")
|
||||
pos1 = reader1.manager.current_position.copy()
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Session 2: Resume and navigate backward
|
||||
reader2 = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
reader2.load_epub(self.epub_path)
|
||||
|
||||
# Verify we're at page 1
|
||||
self.assertEqual(
|
||||
reader2.manager.current_position,
|
||||
pos1,
|
||||
"Should resume at page 1"
|
||||
)
|
||||
|
||||
# Try to go back
|
||||
prev_page = reader2.previous_page()
|
||||
|
||||
# This is the critical assertion - if this fails, backward nav is broken
|
||||
self.assertIsNotNone(
|
||||
prev_page,
|
||||
"CRITICAL: previous_page() returned None after resume - this indicates a pyWebLayout bug"
|
||||
)
|
||||
|
||||
# Verify we're back at page 0
|
||||
final_pos = reader2.manager.current_position.copy()
|
||||
self.assertEqual(
|
||||
final_pos,
|
||||
pos0,
|
||||
"Should be back at initial position"
|
||||
)
|
||||
|
||||
reader2.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
Comprehensive tests for boot recovery and resume functionality.
|
||||
|
||||
Tests cover:
|
||||
- Saving state when closing reader
|
||||
- Resuming from saved state with a new reader instance
|
||||
- Restoring reading position (page/chapter)
|
||||
- Restoring settings (font size, spacing, etc.)
|
||||
- Restoring bookmarks
|
||||
- Handling state across multiple books
|
||||
- Error recovery (corrupt state, missing books)
|
||||
- Bookmark-based position restoration
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from dreader.application import EbookReader
|
||||
from dreader.state import StateManager, AppState, BookState, Settings, EreaderMode, OverlayState
|
||||
|
||||
|
||||
class TestBootRecovery(unittest.TestCase):
|
||||
"""Test application state persistence and recovery across reader instances"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment with temporary directories"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
|
||||
self.highlights_dir = Path(self.temp_dir) / "highlights"
|
||||
self.state_file = Path(self.temp_dir) / "state.json"
|
||||
|
||||
self.bookmarks_dir.mkdir(exist_ok=True)
|
||||
self.highlights_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_save_and_restore_reading_position(self):
|
||||
"""Test saving current position and restoring it in a new reader"""
|
||||
# Create first reader instance
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
|
||||
# Load book and navigate to middle
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Navigate forward several pages
|
||||
for _ in range(5):
|
||||
reader1.next_page()
|
||||
|
||||
# Get position before saving
|
||||
original_position = reader1.get_position_info()
|
||||
original_progress = reader1.get_reading_progress()
|
||||
|
||||
# Save position using special auto-resume bookmark
|
||||
reader1.save_position("__auto_resume__")
|
||||
|
||||
# Close reader
|
||||
reader1.close()
|
||||
|
||||
# Create new reader instance
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
|
||||
# Load same book
|
||||
reader2.load_epub(self.epub_path)
|
||||
|
||||
# Restore position
|
||||
success = reader2.load_position("__auto_resume__")
|
||||
|
||||
self.assertTrue(success, "Failed to load auto-resume position")
|
||||
|
||||
# Verify position matches
|
||||
restored_position = reader2.get_position_info()
|
||||
restored_progress = reader2.get_reading_progress()
|
||||
|
||||
# Compare positions using the position dict
|
||||
self.assertEqual(original_position.get('position'), restored_position.get('position'),
|
||||
f"Position mismatch: {original_position} vs {restored_position}")
|
||||
self.assertAlmostEqual(original_progress, restored_progress,
|
||||
places=2, msg="Progress percentage mismatch")
|
||||
|
||||
reader2.close()
|
||||
|
||||
def test_save_and_restore_settings(self):
|
||||
"""Test saving settings and restoring them in a new reader"""
|
||||
# Create first reader
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Change settings
|
||||
reader1.increase_font_size()
|
||||
reader1.increase_font_size()
|
||||
reader1.set_line_spacing(10)
|
||||
reader1.set_inter_block_spacing(25)
|
||||
|
||||
# Get settings
|
||||
original_font_scale = reader1.base_font_scale
|
||||
original_line_spacing = reader1.page_style.line_spacing
|
||||
original_inter_block = reader1.page_style.inter_block_spacing
|
||||
|
||||
# Create state manager and save settings
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state_manager.update_settings({
|
||||
'font_scale': original_font_scale,
|
||||
'line_spacing': original_line_spacing,
|
||||
'inter_block_spacing': original_inter_block
|
||||
})
|
||||
state_manager.save_state(force=True)
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Create new reader
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(self.epub_path)
|
||||
|
||||
# Load state and apply settings
|
||||
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state_manager2.load_state()
|
||||
settings_dict = state_manager2.get_settings().to_dict()
|
||||
|
||||
reader2.apply_settings(settings_dict)
|
||||
|
||||
# Verify settings match
|
||||
self.assertAlmostEqual(original_font_scale, reader2.base_font_scale, places=2,
|
||||
msg="Font scale mismatch")
|
||||
self.assertEqual(original_line_spacing, reader2.page_style.line_spacing,
|
||||
"Line spacing mismatch")
|
||||
self.assertEqual(original_inter_block, reader2.page_style.inter_block_spacing,
|
||||
"Inter-block spacing mismatch")
|
||||
|
||||
reader2.close()
|
||||
|
||||
def test_save_and_restore_bookmarks(self):
|
||||
"""Test that bookmarks persist across reader instances"""
|
||||
# Create first reader
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Navigate and create bookmarks
|
||||
reader1.next_page()
|
||||
reader1.next_page()
|
||||
reader1.save_position("bookmark1")
|
||||
|
||||
reader1.next_page()
|
||||
reader1.next_page()
|
||||
reader1.next_page()
|
||||
reader1.save_position("bookmark2")
|
||||
|
||||
# Get bookmark list
|
||||
original_bookmarks = reader1.list_saved_positions()
|
||||
self.assertGreater(len(original_bookmarks), 0, "No bookmarks saved")
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Create new reader
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(self.epub_path)
|
||||
|
||||
# Check bookmarks exist
|
||||
restored_bookmarks = reader2.list_saved_positions()
|
||||
|
||||
self.assertIn("bookmark1", restored_bookmarks, "bookmark1 not found")
|
||||
self.assertIn("bookmark2", restored_bookmarks, "bookmark2 not found")
|
||||
|
||||
# Test loading each bookmark
|
||||
success1 = reader2.load_position("bookmark1")
|
||||
self.assertTrue(success1, "Failed to load bookmark1")
|
||||
|
||||
success2 = reader2.load_position("bookmark2")
|
||||
self.assertTrue(success2, "Failed to load bookmark2")
|
||||
|
||||
reader2.close()
|
||||
|
||||
def test_full_state_persistence_workflow(self):
|
||||
"""Test complete workflow: read, change settings, save, close, restore"""
|
||||
# Session 1: Initial reading session
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Simulate reading session
|
||||
for _ in range(3):
|
||||
reader1.next_page()
|
||||
|
||||
reader1.increase_font_size()
|
||||
reader1.set_line_spacing(8)
|
||||
|
||||
# Save everything
|
||||
reader1.save_position("__auto_resume__")
|
||||
reader1.save_position("my_bookmark")
|
||||
|
||||
session1_position = reader1.get_position_info()
|
||||
session1_progress = reader1.get_reading_progress()
|
||||
session1_font = reader1.base_font_scale
|
||||
session1_spacing = reader1.page_style.line_spacing
|
||||
|
||||
# Save state
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state_manager.set_current_book(BookState(
|
||||
path=self.epub_path,
|
||||
title=reader1.book_title or "Test Book",
|
||||
author=reader1.book_author or "Test Author"
|
||||
))
|
||||
state_manager.update_settings({
|
||||
'font_scale': session1_font,
|
||||
'line_spacing': session1_spacing
|
||||
})
|
||||
state_manager.save_state(force=True)
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Session 2: Resume reading
|
||||
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
loaded_state = state_manager2.load_state()
|
||||
|
||||
# Verify state loaded
|
||||
self.assertIsNotNone(loaded_state.current_book, "No current book in state")
|
||||
self.assertEqual(loaded_state.current_book.path, self.epub_path,
|
||||
"Book path mismatch")
|
||||
|
||||
# Create new reader and restore
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(loaded_state.current_book.path)
|
||||
reader2.apply_settings(loaded_state.settings.to_dict())
|
||||
reader2.load_position("__auto_resume__")
|
||||
|
||||
# Verify restoration
|
||||
session2_position = reader2.get_position_info()
|
||||
session2_progress = reader2.get_reading_progress()
|
||||
|
||||
self.assertEqual(session1_position.get('position'), session2_position.get('position'),
|
||||
"Position not restored correctly")
|
||||
self.assertAlmostEqual(session1_progress, session2_progress, places=2,
|
||||
msg="Progress not restored correctly")
|
||||
self.assertAlmostEqual(session1_font, reader2.base_font_scale, places=2,
|
||||
msg="Font scale not restored correctly")
|
||||
self.assertEqual(session1_spacing, reader2.page_style.line_spacing,
|
||||
"Line spacing not restored correctly")
|
||||
|
||||
# Verify bookmark exists
|
||||
bookmarks = reader2.list_saved_positions()
|
||||
self.assertIn("my_bookmark", bookmarks, "Bookmark lost after restart")
|
||||
|
||||
reader2.close()
|
||||
|
||||
def test_multiple_books_separate_state(self):
|
||||
"""Test that different books maintain separate positions and bookmarks"""
|
||||
epub_path = self.epub_path
|
||||
|
||||
# Book 1 - First session
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(epub_path)
|
||||
|
||||
for _ in range(3):
|
||||
reader1.next_page()
|
||||
|
||||
reader1.save_position("__auto_resume__")
|
||||
book1_position = reader1.get_position_info()
|
||||
book1_progress = reader1.get_reading_progress()
|
||||
book1_doc_id = reader1.document_id
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Book 1 - Second session (simulate reopening)
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(epub_path)
|
||||
reader2.load_position("__auto_resume__")
|
||||
|
||||
# Verify we're at the same position
|
||||
book1_position_restored = reader2.get_position_info()
|
||||
book1_progress_restored = reader2.get_reading_progress()
|
||||
|
||||
self.assertEqual(book1_position.get('position'), book1_position_restored.get('position'),
|
||||
"Book position not preserved across sessions")
|
||||
self.assertAlmostEqual(book1_progress, book1_progress_restored, places=2,
|
||||
msg="Book progress not preserved")
|
||||
|
||||
# Now navigate further and save again
|
||||
for _ in range(2):
|
||||
reader2.next_page()
|
||||
|
||||
reader2.save_position("__auto_resume__")
|
||||
book1_position_updated = reader2.get_position_info()
|
||||
book1_progress_updated = reader2.get_reading_progress()
|
||||
|
||||
reader2.close()
|
||||
|
||||
# Book 1 - Third session, verify updated position
|
||||
reader3 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader3.load_epub(epub_path)
|
||||
reader3.load_position("__auto_resume__")
|
||||
|
||||
book1_position_final = reader3.get_position_info()
|
||||
book1_progress_final = reader3.get_reading_progress()
|
||||
|
||||
self.assertEqual(book1_position_updated.get('position'), book1_position_final.get('position'),
|
||||
"Updated position not preserved")
|
||||
self.assertAlmostEqual(book1_progress_updated, book1_progress_final, places=2,
|
||||
msg="Updated progress not preserved")
|
||||
|
||||
reader3.close()
|
||||
|
||||
def test_corrupt_state_file_recovery(self):
|
||||
"""Test graceful handling of corrupt state file"""
|
||||
# Create corrupt state file
|
||||
with open(self.state_file, 'w') as f:
|
||||
f.write("{ corrupt json content ][[ }")
|
||||
|
||||
# Try to load state
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state = state_manager.load_state()
|
||||
|
||||
# Should return default state, not crash
|
||||
self.assertIsNotNone(state)
|
||||
self.assertEqual(state.mode, EreaderMode.LIBRARY)
|
||||
self.assertIsNone(state.current_book)
|
||||
|
||||
# Verify backup was created
|
||||
backup_file = self.state_file.with_suffix('.json.backup')
|
||||
self.assertTrue(backup_file.exists(), "Backup file not created for corrupt state")
|
||||
|
||||
def test_missing_book_in_state(self):
|
||||
"""Test handling when saved state references a missing book"""
|
||||
# Create valid state pointing to non-existent book
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state_manager.set_current_book(BookState(
|
||||
path="/nonexistent/book.epub",
|
||||
title="Missing Book",
|
||||
author="Ghost Author"
|
||||
))
|
||||
state_manager.save_state(force=True)
|
||||
|
||||
# Load state
|
||||
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state = state_manager2.load_state()
|
||||
|
||||
# State loads successfully
|
||||
self.assertIsNotNone(state.current_book)
|
||||
self.assertEqual(state.current_book.path, "/nonexistent/book.epub")
|
||||
|
||||
# But trying to load the book should fail gracefully
|
||||
reader = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
success = reader.load_epub(state.current_book.path)
|
||||
|
||||
self.assertFalse(success, "Should fail to load non-existent book")
|
||||
self.assertFalse(reader.is_loaded(), "Reader should not be in loaded state")
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_no_state_file_cold_start(self):
|
||||
"""Test first boot with no existing state file"""
|
||||
# Ensure no state file exists
|
||||
if self.state_file.exists():
|
||||
self.state_file.unlink()
|
||||
|
||||
# Create state manager
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
state = state_manager.load_state()
|
||||
|
||||
# Should get default state
|
||||
self.assertEqual(state.mode, EreaderMode.LIBRARY)
|
||||
self.assertIsNone(state.current_book)
|
||||
self.assertEqual(state.overlay, OverlayState.NONE)
|
||||
self.assertEqual(state.settings.font_scale, 1.0)
|
||||
|
||||
# Should be able to save new state
|
||||
success = state_manager.save_state(force=True)
|
||||
self.assertTrue(success, "Failed to save initial state")
|
||||
self.assertTrue(self.state_file.exists(), "State file not created")
|
||||
|
||||
def test_position_survives_settings_change(self):
|
||||
"""Test that position is preserved when settings change"""
|
||||
# Create reader and navigate
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Navigate to specific position
|
||||
for _ in range(4):
|
||||
reader1.next_page()
|
||||
|
||||
reader1.save_position("__auto_resume__")
|
||||
position1_info = reader1.get_position_info()
|
||||
|
||||
# Change font size (which re-paginates)
|
||||
reader1.increase_font_size()
|
||||
reader1.increase_font_size()
|
||||
|
||||
# Position might change due to repagination, but logical position is preserved
|
||||
# Save again
|
||||
reader1.save_position("__auto_resume__")
|
||||
position_after_resize_info = reader1.get_position_info()
|
||||
position_after_resize_progress = reader1.get_reading_progress()
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Create new reader with same settings
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(self.epub_path)
|
||||
|
||||
# Apply same font size
|
||||
reader2.increase_font_size()
|
||||
reader2.increase_font_size()
|
||||
|
||||
# Load position
|
||||
reader2.load_position("__auto_resume__")
|
||||
position2_info = reader2.get_position_info()
|
||||
position2_progress = reader2.get_reading_progress()
|
||||
|
||||
# Should match the position after resize, not the original
|
||||
self.assertEqual(position_after_resize_info.get('position'), position2_info.get('position'),
|
||||
"Position not preserved after font size change")
|
||||
self.assertAlmostEqual(position_after_resize_progress, position2_progress, places=2,
|
||||
msg="Progress not preserved after font size change")
|
||||
|
||||
reader2.close()
|
||||
|
||||
def test_chapter_position_restoration(self):
|
||||
"""Test that chapter context is preserved across sessions"""
|
||||
# Create reader and jump to specific chapter
|
||||
reader1 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader1.load_epub(self.epub_path)
|
||||
|
||||
# Get chapters
|
||||
chapters = reader1.get_chapters()
|
||||
if len(chapters) < 2:
|
||||
self.skipTest("Test EPUB needs at least 2 chapters")
|
||||
|
||||
# Jump to second chapter
|
||||
_, chapter_idx = chapters[1]
|
||||
reader1.jump_to_chapter(chapter_idx)
|
||||
|
||||
# Navigate a bit within the chapter
|
||||
reader1.next_page()
|
||||
|
||||
# Save position
|
||||
reader1.save_position("__auto_resume__")
|
||||
chapter1_position = reader1.get_position_info()
|
||||
chapter1_progress = reader1.get_reading_progress()
|
||||
|
||||
reader1.close()
|
||||
|
||||
# Create new reader and restore
|
||||
reader2 = EbookReader(
|
||||
bookmarks_dir=str(self.bookmarks_dir),
|
||||
highlights_dir=str(self.highlights_dir)
|
||||
)
|
||||
reader2.load_epub(self.epub_path)
|
||||
reader2.load_position("__auto_resume__")
|
||||
|
||||
# Verify we're at the right position
|
||||
chapter2_position = reader2.get_position_info()
|
||||
chapter2_progress = reader2.get_reading_progress()
|
||||
|
||||
self.assertEqual(chapter1_position.get('position'), chapter2_position.get('position'),
|
||||
"Chapter position not restored correctly")
|
||||
self.assertAlmostEqual(chapter1_progress, chapter2_progress, places=2,
|
||||
msg="Chapter progress not restored correctly")
|
||||
|
||||
reader2.close()
|
||||
|
||||
|
||||
class TestStateManagerAsync(unittest.TestCase):
|
||||
"""Test StateManager async functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.state_file = Path(self.temp_dir) / "state.json"
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_async_auto_save(self):
|
||||
"""Test that async auto-save works"""
|
||||
async def test_auto_save():
|
||||
# Create state manager with short interval
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=1)
|
||||
|
||||
# Start auto-save
|
||||
state_manager.start_auto_save()
|
||||
|
||||
# Make a change
|
||||
state_manager.set_mode(EreaderMode.READING)
|
||||
|
||||
# Wait for auto-save to trigger
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# Stop auto-save
|
||||
await state_manager.stop_auto_save(save_final=True)
|
||||
|
||||
# Verify file was saved
|
||||
self.assertTrue(self.state_file.exists(), "State file not created")
|
||||
|
||||
# Load and verify
|
||||
with open(self.state_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.assertEqual(data['mode'], 'reading')
|
||||
|
||||
# Run async test
|
||||
asyncio.run(test_auto_save())
|
||||
|
||||
def test_async_save_with_lock(self):
|
||||
"""Test that async saves are thread-safe"""
|
||||
async def test_concurrent_saves():
|
||||
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
|
||||
|
||||
# Make multiple concurrent saves
|
||||
tasks = []
|
||||
for i in range(10):
|
||||
state_manager.update_setting('brightness', i)
|
||||
tasks.append(state_manager.save_state_async(force=True))
|
||||
|
||||
# Wait for all saves
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# All should succeed
|
||||
self.assertTrue(all(results), "Some saves failed")
|
||||
|
||||
# File should exist and be valid
|
||||
self.assertTrue(self.state_file.exists())
|
||||
|
||||
# Load and verify (should have last value)
|
||||
with open(self.state_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Brightness should be set (exact value depends on race, but should be 0-9)
|
||||
self.assertIn(data['settings']['brightness'], range(10))
|
||||
|
||||
asyncio.run(test_concurrent_saves())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test that images render correctly in EPUB files.
|
||||
|
||||
This test verifies that:
|
||||
1. All images in the EPUB are loaded with correct dimensions
|
||||
2. Images can be navigated to without errors
|
||||
3. Pages with images render successfully
|
||||
4. The rendered pages contain actual image content (not blank)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from dreader.application import EbookReader
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
|
||||
def test_epub_images():
|
||||
"""Test that EPUB images render correctly."""
|
||||
|
||||
# Create reader
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load EPUB
|
||||
epub_path = "tests/data/library-epub/pg11-images-3.epub"
|
||||
success = reader.load_epub(epub_path)
|
||||
|
||||
assert success, "Failed to load EPUB"
|
||||
assert reader.book_title == "Alice's Adventures in Wonderland"
|
||||
|
||||
# Check that images were parsed
|
||||
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
|
||||
assert len(images) >= 1, f"Expected at least 1 image, found {len(images)}"
|
||||
|
||||
# Check that all images have dimensions set
|
||||
for img in images:
|
||||
assert img.width is not None, f"Image {img.source} has no width"
|
||||
assert img.height is not None, f"Image {img.source} has no height"
|
||||
assert img.width > 0, f"Image {img.source} has invalid width: {img.width}"
|
||||
assert img.height > 0, f"Image {img.source} has invalid height: {img.height}"
|
||||
|
||||
# Check that image is loaded into memory
|
||||
assert hasattr(img, '_loaded_image'), f"Image {img.source} not loaded"
|
||||
assert img._loaded_image is not None, f"Image {img.source} _loaded_image is None"
|
||||
|
||||
# Test navigation through first 15 pages (which should include all images)
|
||||
for page_num in range(15):
|
||||
page_img = reader.get_current_page()
|
||||
|
||||
assert page_img is not None, f"Page {page_num + 1} failed to render"
|
||||
assert isinstance(page_img, Image.Image), f"Page {page_num + 1} is not a PIL Image"
|
||||
assert page_img.size == (800, 1200), f"Page {page_num + 1} has wrong size: {page_img.size}"
|
||||
|
||||
# Check that page has some non-white content
|
||||
arr = np.array(page_img.convert('RGB'))
|
||||
non_white_pixels = np.sum(arr < 255)
|
||||
|
||||
assert non_white_pixels > 100, f"Page {page_num + 1} appears to be blank (only {non_white_pixels} non-white pixels)"
|
||||
|
||||
# Navigate to next page
|
||||
if page_num < 14:
|
||||
next_result = reader.next_page()
|
||||
if next_result is None:
|
||||
# It's OK to reach end of book early
|
||||
break
|
||||
|
||||
|
||||
def test_cover_image():
|
||||
"""Specifically test that the cover image renders."""
|
||||
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
reader.load_epub("tests/data/library-epub/pg11-images-3.epub")
|
||||
|
||||
# The first page should have the cover image
|
||||
page_img = reader.get_current_page()
|
||||
assert page_img is not None, "Cover page failed to render"
|
||||
|
||||
# Save for visual inspection
|
||||
output_path = "/tmp/epub_cover_test.png"
|
||||
page_img.save(output_path)
|
||||
|
||||
# Check that it has significant content (the cover image)
|
||||
arr = np.array(page_img.convert('RGB'))
|
||||
non_white_pixels = np.sum(arr < 255)
|
||||
|
||||
# The cover page should have substantial content
|
||||
assert non_white_pixels > 10000, f"Cover page has too few non-white pixels: {non_white_pixels}"
|
||||
|
||||
|
||||
def test_multiple_epub_images():
|
||||
"""Test images across multiple EPUB files."""
|
||||
|
||||
epub_files = [
|
||||
("tests/data/library-epub/pg11-images-3.epub", "Alice's Adventures in Wonderland"),
|
||||
("tests/data/library-epub/pg16328-images-3.epub", "Beowulf: An Anglo-Saxon Epic Poem"),
|
||||
("tests/data/library-epub/pg5200-images-3.epub", "Metamorphosis"),
|
||||
]
|
||||
|
||||
for epub_path, expected_title in epub_files:
|
||||
reader = EbookReader(page_size=(800, 1200))
|
||||
success = reader.load_epub(epub_path)
|
||||
|
||||
assert success, f"Failed to load {epub_path}"
|
||||
assert reader.book_title == expected_title
|
||||
|
||||
# Check that at least one image exists
|
||||
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
|
||||
assert len(images) >= 1, f"{epub_path} should have at least 1 image"
|
||||
|
||||
# Check first image is valid
|
||||
img = images[0]
|
||||
assert img.width > 0 and img.height > 0, f"Invalid dimensions in {epub_path}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests directly
|
||||
print("Testing EPUB images...")
|
||||
|
||||
print("\n1. Testing all images load and render...")
|
||||
test_epub_images()
|
||||
print("✓ PASSED")
|
||||
|
||||
print("\n2. Testing cover image...")
|
||||
test_cover_image()
|
||||
print("✓ PASSED")
|
||||
|
||||
print("\n3. Testing multiple EPUB images...")
|
||||
test_multiple_epub_images()
|
||||
print("✓ PASSED")
|
||||
|
||||
print("\n✓ All tests passed!")
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Unit tests for example scripts.
|
||||
|
||||
This test suite validates that all example scripts:
|
||||
1. Can be imported without errors (syntax checks, import validation)
|
||||
2. Have valid import statements
|
||||
3. Can run their main functions without crashing (when applicable)
|
||||
|
||||
This helps catch issues like:
|
||||
- Incorrect import paths
|
||||
- Missing dependencies
|
||||
- API breakages that affect examples
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import importlib.util
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestExampleImports(unittest.TestCase):
|
||||
"""Test that all example scripts can be imported successfully"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Get the project root directory
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.examples_dir = self.project_root / "examples"
|
||||
|
||||
# Add project root to Python path if not already there
|
||||
if str(self.project_root) not in sys.path:
|
||||
sys.path.insert(0, str(self.project_root))
|
||||
|
||||
def _import_module_from_file(self, file_path: Path):
|
||||
"""
|
||||
Import a Python module from a file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to the Python file
|
||||
|
||||
Returns:
|
||||
The imported module
|
||||
|
||||
Raises:
|
||||
Any import errors that occur
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load spec for {file_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[file_path.stem] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_word_selection_highlighting_imports(self):
|
||||
"""Test word_selection_highlighting.py can be imported"""
|
||||
example_file = self.examples_dir / "word_selection_highlighting.py"
|
||||
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
|
||||
|
||||
try:
|
||||
module = self._import_module_from_file(example_file)
|
||||
|
||||
# Verify key components are available
|
||||
self.assertTrue(hasattr(module, 'draw_highlight'))
|
||||
self.assertTrue(hasattr(module, 'example_1_single_word_selection'))
|
||||
self.assertTrue(hasattr(module, 'example_2_range_selection'))
|
||||
self.assertTrue(hasattr(module, 'example_3_interactive_word_lookup'))
|
||||
self.assertTrue(hasattr(module, 'example_4_multi_word_annotation'))
|
||||
self.assertTrue(hasattr(module, 'example_5_link_highlighting'))
|
||||
|
||||
except ImportError as e:
|
||||
self.fail(f"Failed to import word_selection_highlighting.py: {e}")
|
||||
|
||||
def test_demo_pagination_imports(self):
|
||||
"""Test demo_pagination.py can be imported"""
|
||||
example_file = self.examples_dir / "demo_pagination.py"
|
||||
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
|
||||
|
||||
try:
|
||||
module = self._import_module_from_file(example_file)
|
||||
self.assertTrue(hasattr(module, 'main'))
|
||||
except ImportError as e:
|
||||
self.fail(f"Failed to import demo_pagination.py: {e}")
|
||||
|
||||
def test_demo_toc_overlay_imports(self):
|
||||
"""Test demo_toc_overlay.py can be imported"""
|
||||
example_file = self.examples_dir / "demo_toc_overlay.py"
|
||||
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
|
||||
|
||||
try:
|
||||
module = self._import_module_from_file(example_file)
|
||||
self.assertTrue(hasattr(module, 'main'))
|
||||
except ImportError as e:
|
||||
self.fail(f"Failed to import demo_toc_overlay.py: {e}")
|
||||
|
||||
def test_demo_settings_overlay_imports(self):
|
||||
"""Test demo_settings_overlay.py can be imported"""
|
||||
example_file = self.examples_dir / "demo_settings_overlay.py"
|
||||
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
|
||||
|
||||
try:
|
||||
module = self._import_module_from_file(example_file)
|
||||
self.assertTrue(hasattr(module, 'main'))
|
||||
except ImportError as e:
|
||||
self.fail(f"Failed to import demo_settings_overlay.py: {e}")
|
||||
|
||||
def test_library_reading_integration_imports(self):
|
||||
"""Test library_reading_integration.py can be imported"""
|
||||
example_file = self.examples_dir / "library_reading_integration.py"
|
||||
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
|
||||
|
||||
try:
|
||||
module = self._import_module_from_file(example_file)
|
||||
self.assertTrue(hasattr(module, 'main'))
|
||||
self.assertTrue(hasattr(module, 'simulate_mode_transition_workflow'))
|
||||
except ImportError as e:
|
||||
self.fail(f"Failed to import library_reading_integration.py: {e}")
|
||||
|
||||
def test_all_examples_have_correct_dreader_imports(self):
|
||||
"""
|
||||
Verify all example scripts use correct import paths for dreader classes.
|
||||
|
||||
This test specifically checks that examples don't use outdated import paths
|
||||
like 'from dreader.application import' when they should use 'from dreader import'.
|
||||
"""
|
||||
# Get all Python files in examples directory
|
||||
example_files = list(self.examples_dir.glob("*.py"))
|
||||
|
||||
problematic_imports = []
|
||||
|
||||
for example_file in example_files:
|
||||
# Skip __init__.py and other special files
|
||||
if example_file.name.startswith('_'):
|
||||
continue
|
||||
|
||||
with open(example_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for problematic import patterns
|
||||
if 'from pyWebLayout.io.gesture import' in content:
|
||||
problematic_imports.append(
|
||||
f"{example_file.name}: Uses 'from pyWebLayout.io.gesture import' "
|
||||
f"(should be 'from dreader import')"
|
||||
)
|
||||
|
||||
if 'from dreader.application import EbookReader' in content:
|
||||
# This is acceptable, but check if TouchEvent/GestureType are also imported correctly
|
||||
if 'from pyWebLayout.io.gesture import TouchEvent' in content:
|
||||
problematic_imports.append(
|
||||
f"{example_file.name}: Mixes dreader.application and pyWebLayout.io.gesture imports"
|
||||
)
|
||||
|
||||
if problematic_imports:
|
||||
self.fail(
|
||||
"Found problematic imports in example files:\n" +
|
||||
"\n".join(f" - {issue}" for issue in problematic_imports)
|
||||
)
|
||||
|
||||
|
||||
class TestExampleFunctions(unittest.TestCase):
|
||||
"""Test key functionality in example scripts"""
|
||||
|
||||
def test_draw_highlight_function(self):
|
||||
"""Test the draw_highlight function from word_selection_highlighting"""
|
||||
from PIL import Image
|
||||
|
||||
# Import the module
|
||||
project_root = Path(__file__).parent.parent
|
||||
example_file = project_root / "examples" / "word_selection_highlighting.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("word_selection_highlighting", example_file)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Create a test image
|
||||
test_image = Image.new('RGBA', (100, 100), (255, 255, 255, 255))
|
||||
|
||||
# Test the draw_highlight function
|
||||
bounds = (10, 10, 50, 20)
|
||||
result = module.draw_highlight(test_image, bounds)
|
||||
|
||||
# Verify the result is an image
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, (100, 100))
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
|
||||
|
||||
class TestExampleDocumentation(unittest.TestCase):
|
||||
"""Test that examples have proper documentation"""
|
||||
|
||||
def test_all_examples_have_docstrings(self):
|
||||
"""Verify all example scripts have module docstrings"""
|
||||
project_root = Path(__file__).parent.parent
|
||||
examples_dir = project_root / "examples"
|
||||
|
||||
example_files = [
|
||||
f for f in examples_dir.glob("*.py")
|
||||
if not f.name.startswith('_') and f.name not in ['__init__.py']
|
||||
]
|
||||
|
||||
missing_docstrings = []
|
||||
|
||||
for example_file in example_files:
|
||||
spec = importlib.util.spec_from_file_location(example_file.stem, example_file)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not module.__doc__ or len(module.__doc__.strip()) < 10:
|
||||
missing_docstrings.append(example_file.name)
|
||||
except:
|
||||
# If module can't be loaded, skip docstring check
|
||||
# (import test will catch the error)
|
||||
pass
|
||||
|
||||
if missing_docstrings:
|
||||
self.fail(
|
||||
f"Examples missing proper docstrings: {', '.join(missing_docstrings)}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -7,6 +7,7 @@ and verify that tap detection works correctly.
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from dreader import LibraryManager
|
||||
|
||||
|
||||
@@ -39,16 +40,21 @@ class TestLibraryInteraction(unittest.TestCase):
|
||||
self.assertIn('filename', book)
|
||||
|
||||
def test_library_table_creation(self):
|
||||
"""Test that library table can be created"""
|
||||
"""Test that library table can be created with pagination"""
|
||||
books = self.library.scan_library()
|
||||
table = self.library.create_library_table()
|
||||
|
||||
# Table should exist
|
||||
self.assertIsNotNone(table)
|
||||
|
||||
# Table should have body rows matching book count
|
||||
# Table should have body rows for 2-column grid layout
|
||||
# With pagination, we only show books_per_page books, not all books
|
||||
# Calculate expected rows based on current page's books
|
||||
books_on_page = min(self.library.books_per_page, len(books) - (self.library.current_page * self.library.books_per_page))
|
||||
# Each pair of books gets 2 rows (cover row + detail row)
|
||||
expected_rows = ((books_on_page + 1) // 2) * 2
|
||||
body_rows = list(table.body_rows())
|
||||
self.assertEqual(len(body_rows), len(books))
|
||||
self.assertEqual(len(body_rows), expected_rows)
|
||||
|
||||
def test_library_rendering(self):
|
||||
"""Test that library can be rendered to image"""
|
||||
@@ -136,7 +142,7 @@ class TestLibraryInteraction(unittest.TestCase):
|
||||
self.assertIsNone(selected_path, "Tap below last book should not select anything")
|
||||
|
||||
def test_multiple_taps(self):
|
||||
"""Test that multiple taps work correctly"""
|
||||
"""Test that multiple taps work correctly with 2-column grid layout"""
|
||||
books = self.library.scan_library()
|
||||
|
||||
if len(books) < 3:
|
||||
@@ -145,16 +151,20 @@ class TestLibraryInteraction(unittest.TestCase):
|
||||
self.library.create_library_table()
|
||||
self.library.render_library()
|
||||
|
||||
# Tap first book (row 0: y=60-180)
|
||||
# In 2-column layout:
|
||||
# Books 0 and 1 are in the first pair (rows 0-1: cover and detail)
|
||||
# Books 2 and 3 are in the second pair (rows 2-3: cover and detail)
|
||||
|
||||
# Tap first book (left column, first pair cover row)
|
||||
path1 = self.library.handle_library_tap(x=100, y=100)
|
||||
self.assertEqual(path1, books[0]['path'])
|
||||
|
||||
# Tap second book (row 1: y=181-301)
|
||||
path2 = self.library.handle_library_tap(x=400, y=250)
|
||||
# Tap second book (right column, first pair cover row)
|
||||
path2 = self.library.handle_library_tap(x=500, y=100)
|
||||
self.assertEqual(path2, books[1]['path'])
|
||||
|
||||
# Tap third book (row 2: y=302-422)
|
||||
path3 = self.library.handle_library_tap(x=400, y=360)
|
||||
# Tap third book (left column, second pair cover row)
|
||||
path3 = self.library.handle_library_tap(x=100, y=360)
|
||||
self.assertEqual(path3, books[2]['path'])
|
||||
|
||||
# All should be different
|
||||
@@ -162,6 +172,79 @@ class TestLibraryInteraction(unittest.TestCase):
|
||||
self.assertNotEqual(path2, path3)
|
||||
self.assertNotEqual(path1, path3)
|
||||
|
||||
def test_pagination(self):
|
||||
"""Test library pagination with fake book data"""
|
||||
# Create fake books (20 books to ensure multiple pages)
|
||||
fake_books = []
|
||||
for i in range(20):
|
||||
fake_books.append({
|
||||
'path': f'/fake/path/book_{i}.epub',
|
||||
'title': f'Book Title {i}',
|
||||
'author': f'Author {i}',
|
||||
'filename': f'book_{i}.epub',
|
||||
'cover_data': None,
|
||||
'cover_path': None
|
||||
})
|
||||
|
||||
# Create library with 6 books per page
|
||||
library = LibraryManager(
|
||||
library_path=str(self.library_path),
|
||||
page_size=(800, 1200),
|
||||
books_per_page=6
|
||||
)
|
||||
library.books = fake_books
|
||||
|
||||
# Test initial state
|
||||
self.assertEqual(library.current_page, 0)
|
||||
self.assertEqual(library.get_total_pages(), 4) # 20 books / 6 per page = 4 pages
|
||||
|
||||
# Test creating table for first page
|
||||
table = library.create_library_table()
|
||||
self.assertIsNotNone(table)
|
||||
# 6 books = 3 pairs = 6 rows (3 cover rows + 3 detail rows)
|
||||
body_rows = list(table.body_rows())
|
||||
self.assertEqual(len(body_rows), 6)
|
||||
|
||||
# Test navigation to next page
|
||||
self.assertTrue(library.next_page())
|
||||
self.assertEqual(library.current_page, 1)
|
||||
|
||||
# Create table for second page
|
||||
table = library.create_library_table()
|
||||
body_rows = list(table.body_rows())
|
||||
self.assertEqual(len(body_rows), 6) # Still 6 books on page 2
|
||||
|
||||
# Test navigation to last page
|
||||
library.set_page(3)
|
||||
self.assertEqual(library.current_page, 3)
|
||||
table = library.create_library_table()
|
||||
body_rows = list(table.body_rows())
|
||||
# Page 4 has 2 books (20 - 18 = 2) = 1 pair = 2 rows
|
||||
self.assertEqual(len(body_rows), 2)
|
||||
|
||||
# Test can't go beyond last page
|
||||
self.assertFalse(library.next_page())
|
||||
self.assertEqual(library.current_page, 3)
|
||||
|
||||
# Test navigation to previous page
|
||||
self.assertTrue(library.previous_page())
|
||||
self.assertEqual(library.current_page, 2)
|
||||
|
||||
# Test navigation to first page
|
||||
library.set_page(0)
|
||||
self.assertEqual(library.current_page, 0)
|
||||
|
||||
# Test can't go before first page
|
||||
self.assertFalse(library.previous_page())
|
||||
self.assertEqual(library.current_page, 0)
|
||||
|
||||
# Test invalid page number
|
||||
self.assertFalse(library.set_page(-1))
|
||||
self.assertFalse(library.set_page(100))
|
||||
self.assertEqual(library.current_page, 0) # Should stay on current page
|
||||
|
||||
library.cleanup()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Tests for the unified navigation overlay (TOC + Bookmarks tabs)
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from dreader.application import EbookReader
|
||||
from dreader.state import OverlayState
|
||||
from dreader.gesture import TouchEvent, GestureType, ActionType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reader_with_book():
|
||||
"""Create a reader with a test book loaded"""
|
||||
reader = EbookReader(page_size=(400, 600), margin=10)
|
||||
|
||||
# Load a simple test book
|
||||
test_book = Path(__file__).parent.parent / "examples" / "books" / "hamlet.epub"
|
||||
if test_book.exists():
|
||||
reader.load_epub(str(test_book))
|
||||
else:
|
||||
# Fallback: create simple HTML for testing
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<h1>Chapter 1</h1>
|
||||
<p>This is chapter 1 content</p>
|
||||
<h1>Chapter 2</h1>
|
||||
<p>This is chapter 2 content</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
reader.load_html(html, title="Test Book", author="Test Author", document_id="test")
|
||||
|
||||
yield reader
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_open_navigation_overlay_contents_tab(reader_with_book):
|
||||
"""Test opening navigation overlay with Contents tab active"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Open navigation overlay with contents tab
|
||||
image = reader.open_navigation_overlay(active_tab="contents")
|
||||
|
||||
assert image is not None
|
||||
assert isinstance(image, Image.Image)
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
assert reader.is_overlay_open()
|
||||
|
||||
|
||||
def test_open_navigation_overlay_bookmarks_tab(reader_with_book):
|
||||
"""Test opening navigation overlay with Bookmarks tab active"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Save a bookmark first
|
||||
reader.save_position("Test Bookmark")
|
||||
|
||||
# Open navigation overlay with bookmarks tab
|
||||
image = reader.open_navigation_overlay(active_tab="bookmarks")
|
||||
|
||||
assert image is not None
|
||||
assert isinstance(image, Image.Image)
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
|
||||
|
||||
def test_switch_navigation_tabs(reader_with_book):
|
||||
"""Test switching between Contents and Bookmarks tabs"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Open with contents tab
|
||||
reader.open_navigation_overlay(active_tab="contents")
|
||||
|
||||
# Switch to bookmarks
|
||||
image = reader.switch_navigation_tab("bookmarks")
|
||||
assert image is not None
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
|
||||
# Switch back to contents
|
||||
image = reader.switch_navigation_tab("contents")
|
||||
assert image is not None
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
|
||||
|
||||
def test_close_navigation_overlay(reader_with_book):
|
||||
"""Test closing navigation overlay"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Open overlay
|
||||
reader.open_navigation_overlay()
|
||||
assert reader.is_overlay_open()
|
||||
|
||||
# Close overlay
|
||||
image = reader.close_overlay()
|
||||
assert image is not None
|
||||
assert not reader.is_overlay_open()
|
||||
assert reader.get_overlay_state() == OverlayState.NONE
|
||||
|
||||
|
||||
def test_navigation_overlay_tab_switching_gesture(reader_with_book):
|
||||
"""Test tab switching via gesture/touch handling"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Open navigation overlay
|
||||
reader.open_navigation_overlay(active_tab="contents")
|
||||
|
||||
# Query the overlay to find the bookmarks tab button
|
||||
# This would normally be done by finding the coordinates of the "Bookmarks" tab
|
||||
# For now, we test that the switch method works
|
||||
result = reader.switch_navigation_tab("bookmarks")
|
||||
|
||||
assert result is not None
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
|
||||
|
||||
def test_navigation_overlay_with_no_bookmarks(reader_with_book):
|
||||
"""Test navigation overlay when there are no bookmarks"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Open bookmarks tab (should show "No bookmarks yet")
|
||||
image = reader.open_navigation_overlay(active_tab="bookmarks")
|
||||
|
||||
assert image is not None
|
||||
# The overlay should still open successfully
|
||||
assert reader.get_overlay_state() == OverlayState.NAVIGATION
|
||||
|
||||
|
||||
def test_navigation_overlay_preserves_page_position(reader_with_book):
|
||||
"""Test that opening/closing navigation overlay preserves reading position"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Go to page 2
|
||||
reader.next_page()
|
||||
initial_position = reader.get_position_info()
|
||||
|
||||
# Open and close navigation overlay
|
||||
reader.open_navigation_overlay()
|
||||
reader.close_overlay()
|
||||
|
||||
# Verify position hasn't changed
|
||||
final_position = reader.get_position_info()
|
||||
assert initial_position == final_position
|
||||
|
||||
|
||||
def test_navigation_overlay_chapter_selection(reader_with_book):
|
||||
"""Test selecting a chapter from the navigation overlay"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Get chapters
|
||||
chapters = reader.get_chapters()
|
||||
if len(chapters) < 2:
|
||||
pytest.skip("Test book doesn't have enough chapters")
|
||||
|
||||
# Open navigation overlay
|
||||
reader.open_navigation_overlay(active_tab="contents")
|
||||
|
||||
# Get initial position
|
||||
initial_position = reader.get_position_info()
|
||||
|
||||
# Jump to chapter via the reader method (simulating a tap on chapter)
|
||||
reader.jump_to_chapter(chapters[1][1]) # chapters[1] = (title, index)
|
||||
reader.close_overlay()
|
||||
|
||||
# Verify position changed
|
||||
new_position = reader.get_position_info()
|
||||
assert new_position != initial_position
|
||||
|
||||
|
||||
def test_navigation_overlay_bookmark_selection(reader_with_book):
|
||||
"""Test selecting a bookmark from the navigation overlay"""
|
||||
reader = reader_with_book
|
||||
|
||||
# Save a bookmark at page 1
|
||||
reader.save_position("Bookmark 1")
|
||||
|
||||
# Move to a different page
|
||||
reader.next_page()
|
||||
position_before = reader.get_position_info()
|
||||
|
||||
# Open navigation overlay with bookmarks tab
|
||||
reader.open_navigation_overlay(active_tab="bookmarks")
|
||||
|
||||
# Load the bookmark (simulating a tap on bookmark)
|
||||
page = reader.load_position("Bookmark 1")
|
||||
assert page is not None
|
||||
|
||||
reader.close_overlay()
|
||||
|
||||
# Verify position changed back to bookmark
|
||||
position_after = reader.get_position_info()
|
||||
assert position_after != position_before
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Unit tests for Settings overlay functionality.
|
||||
|
||||
Tests the complete workflow of:
|
||||
1. Opening settings overlay with swipe down gesture
|
||||
2. Adjusting settings (font size, line spacing, etc.)
|
||||
3. Live preview updates
|
||||
4. Closing overlay
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from dreader import (
|
||||
EbookReader,
|
||||
TouchEvent,
|
||||
GestureType,
|
||||
ActionType,
|
||||
OverlayState
|
||||
)
|
||||
|
||||
|
||||
class TestSettingsOverlay(unittest.TestCase):
|
||||
"""Test Settings overlay opening, interaction, and closing"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test reader with a book"""
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
self.reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load a test EPUB - use a larger EPUB for spacing tests
|
||||
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
||||
epubs = list(epub_dir.glob('*.epub'))
|
||||
if not epubs:
|
||||
self.skipTest("No test EPUB files available")
|
||||
|
||||
# Prefer larger EPUBs for better testing of spacing changes
|
||||
# Skip minimal-test.epub as it has too little content
|
||||
epubs = [e for e in epubs if 'minimal' not in e.name]
|
||||
if not epubs:
|
||||
epubs = list(epub_dir.glob('*.epub'))
|
||||
|
||||
test_epub = epubs[0]
|
||||
|
||||
# Debug logging
|
||||
print(f"\n=== EPUB Loading Debug Info ===")
|
||||
print(f"Test EPUB path: {test_epub}")
|
||||
print(f"Absolute path: {test_epub.absolute()}")
|
||||
print(f"File exists: {test_epub.exists()}")
|
||||
print(f"File size: {test_epub.stat().st_size if test_epub.exists() else 'N/A'}")
|
||||
print(f"Is file: {test_epub.is_file() if test_epub.exists() else 'N/A'}")
|
||||
print(f"Readable: {os.access(test_epub, os.R_OK) if test_epub.exists() else 'N/A'}")
|
||||
|
||||
# Test if it's a valid ZIP
|
||||
if test_epub.exists():
|
||||
# Check file magic bytes
|
||||
with open(test_epub, 'rb') as f:
|
||||
first_bytes = f.read(10)
|
||||
print(f"First 10 bytes (hex): {first_bytes.hex()}")
|
||||
print(f"First 10 bytes (ascii): {first_bytes[:4]}")
|
||||
print(f"Is PK header: {first_bytes[:2] == b'PK'}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(test_epub, 'r') as zf:
|
||||
print(f"Valid ZIP: True")
|
||||
print(f"Files in ZIP: {len(zf.namelist())}")
|
||||
print(f"First 3 files: {zf.namelist()[:3]}")
|
||||
except Exception as e:
|
||||
print(f"ZIP validation error: {e}")
|
||||
|
||||
# Try to load
|
||||
success = self.reader.load_epub(str(test_epub))
|
||||
|
||||
if not success:
|
||||
print(f"=== Load failed ===")
|
||||
# Try loading with pyWebLayout directly for more detailed error
|
||||
try:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
book = read_epub(str(test_epub))
|
||||
print(f"Direct pyWebLayout load: SUCCESS (unexpected!)")
|
||||
except Exception as e:
|
||||
print(f"Direct pyWebLayout load error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
self.assertTrue(success, f"Failed to load test EPUB: {test_epub}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up"""
|
||||
self.reader.close()
|
||||
|
||||
def test_open_settings_overlay_directly(self):
|
||||
"""Test opening settings overlay using direct API call"""
|
||||
# Initially no overlay
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
|
||||
# Open settings overlay
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
|
||||
# Should return an image
|
||||
self.assertIsNotNone(overlay_image)
|
||||
self.assertEqual(overlay_image.size, (800, 1200))
|
||||
|
||||
# Overlay should be open
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
self.assertEqual(self.reader.get_overlay_state(), OverlayState.SETTINGS)
|
||||
|
||||
def test_close_settings_overlay_directly(self):
|
||||
"""Test closing settings overlay using direct API call"""
|
||||
# Open overlay first
|
||||
self.reader.open_settings_overlay()
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
# Close overlay
|
||||
page_image = self.reader.close_overlay()
|
||||
|
||||
# Should return base page
|
||||
self.assertIsNotNone(page_image)
|
||||
|
||||
# Overlay should be closed
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
|
||||
|
||||
def test_swipe_down_from_top_opens_settings(self):
|
||||
"""Test that swipe down from top of screen opens settings overlay"""
|
||||
# Create swipe down event from top of screen (y=100, which is < 20% of 1200)
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.SWIPE_DOWN,
|
||||
x=400,
|
||||
y=100
|
||||
)
|
||||
|
||||
# Handle gesture
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should open overlay
|
||||
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
||||
self.assertEqual(response.data['overlay_type'], 'settings')
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
def test_swipe_down_from_middle_does_not_open_settings(self):
|
||||
"""Test that swipe down from middle of screen does NOT open settings"""
|
||||
# Create swipe down event from middle of screen (y=600, which is > 20% of 1200)
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.SWIPE_DOWN,
|
||||
x=400,
|
||||
y=600
|
||||
)
|
||||
|
||||
# Handle gesture
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should not open overlay
|
||||
self.assertEqual(response.action, ActionType.NONE)
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
|
||||
def test_tap_outside_closes_settings_overlay(self):
|
||||
"""Test that tapping outside the settings panel closes it"""
|
||||
# Open overlay first
|
||||
self.reader.open_settings_overlay()
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
# Tap in the far left (outside the centered panel)
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.TAP,
|
||||
x=50, # Well outside panel
|
||||
y=600
|
||||
)
|
||||
|
||||
# Handle gesture
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should close overlay
|
||||
self.assertEqual(response.action, ActionType.OVERLAY_CLOSED)
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
|
||||
def test_font_size_increase(self):
|
||||
"""Test increasing font size through settings overlay"""
|
||||
# Open overlay
|
||||
self.reader.open_settings_overlay()
|
||||
initial_font_scale = self.reader.base_font_scale
|
||||
|
||||
# Get overlay reader to query button positions from the active overlay sub-application
|
||||
overlay_subapp = self.reader._active_overlay
|
||||
if not overlay_subapp:
|
||||
self.skipTest("No active overlay sub-application")
|
||||
|
||||
overlay_reader = overlay_subapp._overlay_reader
|
||||
|
||||
if not overlay_reader or not overlay_reader.manager:
|
||||
self.skipTest("Overlay reader not available for querying")
|
||||
|
||||
# Query the overlay to find the "A+" button link
|
||||
# We'll search for it by looking for links with "setting:font_increase"
|
||||
page = overlay_reader.manager.get_current_page()
|
||||
|
||||
# Try multiple Y positions in the font size row to find the button
|
||||
# Panel is 60% of screen width (480px) centered (x offset = 160)
|
||||
# First setting row should be around y=100-150 in panel coordinates
|
||||
found_button = False
|
||||
tap_x = None
|
||||
tap_y = None
|
||||
|
||||
for y in range(80, 180, 10):
|
||||
for x in range(300, 450, 20): # Right side of panel where buttons are
|
||||
# Translate to panel coordinates
|
||||
panel_x_offset = int((800 - 480) / 2)
|
||||
panel_y_offset = int((1200 - 840) / 2)
|
||||
panel_x = x - panel_x_offset
|
||||
panel_y = y - panel_y_offset
|
||||
|
||||
if panel_x < 0 or panel_y < 0:
|
||||
continue
|
||||
|
||||
result = page.query_point((panel_x, panel_y))
|
||||
if result and result.link_target == "setting:font_increase":
|
||||
tap_x = x
|
||||
tap_y = y
|
||||
found_button = True
|
||||
break
|
||||
if found_button:
|
||||
break
|
||||
|
||||
if not found_button:
|
||||
# Fallback: use approximate coordinates
|
||||
# Based on HTML layout: panel center + right side button
|
||||
tap_x = 550
|
||||
tap_y = 350
|
||||
|
||||
# Tap the increase button (in screen coordinates)
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.TAP,
|
||||
x=tap_x,
|
||||
y=tap_y
|
||||
)
|
||||
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should either change setting or close (depending on whether we hit the button)
|
||||
if response.action == ActionType.SETTING_CHANGED:
|
||||
# Font size should have increased
|
||||
self.assertGreater(self.reader.base_font_scale, initial_font_scale)
|
||||
# Overlay should still be open
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
else:
|
||||
# If we missed the button, that's OK for this test
|
||||
pass
|
||||
|
||||
def test_line_spacing_adjustment(self):
|
||||
"""Test adjusting line spacing through settings overlay"""
|
||||
# Open overlay
|
||||
self.reader.open_settings_overlay()
|
||||
initial_spacing = self.reader.page_style.line_spacing
|
||||
|
||||
# Close overlay for this test (full interaction would require precise coordinates)
|
||||
self.reader.close_overlay()
|
||||
|
||||
# Verify we can adjust line spacing programmatically
|
||||
self.reader.set_line_spacing(initial_spacing + 2)
|
||||
self.assertEqual(self.reader.page_style.line_spacing, initial_spacing + 2)
|
||||
|
||||
def test_settings_values_displayed_in_overlay(self):
|
||||
"""Test that current settings values are shown in the overlay"""
|
||||
# Set specific values
|
||||
self.reader.set_font_size(1.5) # 150%
|
||||
self.reader.set_line_spacing(10)
|
||||
|
||||
# Open overlay
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
self.assertIsNotNone(overlay_image)
|
||||
|
||||
# Overlay should be open with current values
|
||||
# (Visual verification would show "150%" and "10px" in the HTML)
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
def test_multiple_setting_changes(self):
|
||||
"""Test making multiple setting changes in sequence"""
|
||||
initial_font = self.reader.base_font_scale
|
||||
initial_spacing = self.reader.page_style.line_spacing
|
||||
|
||||
# Change font size
|
||||
self.reader.increase_font_size()
|
||||
self.assertNotEqual(self.reader.base_font_scale, initial_font)
|
||||
|
||||
# Change line spacing
|
||||
self.reader.set_line_spacing(initial_spacing + 5)
|
||||
self.assertNotEqual(self.reader.page_style.line_spacing, initial_spacing)
|
||||
|
||||
# Open overlay to verify values
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
self.assertIsNotNone(overlay_image)
|
||||
|
||||
def test_settings_persist_after_overlay_close(self):
|
||||
"""Test that setting changes persist after closing overlay"""
|
||||
# Make a change
|
||||
initial_font = self.reader.base_font_scale
|
||||
self.reader.increase_font_size()
|
||||
new_font = self.reader.base_font_scale
|
||||
|
||||
# Open and close overlay
|
||||
self.reader.open_settings_overlay()
|
||||
self.reader.close_overlay()
|
||||
|
||||
# Settings should still be changed
|
||||
self.assertEqual(self.reader.base_font_scale, new_font)
|
||||
self.assertNotEqual(self.reader.base_font_scale, initial_font)
|
||||
|
||||
def test_overlay_refresh_after_setting_change(self):
|
||||
"""Test that overlay can be refreshed with updated values"""
|
||||
# Open overlay
|
||||
self.reader.open_settings_overlay()
|
||||
|
||||
# Access refresh method through active overlay sub-application
|
||||
overlay_subapp = self.reader._active_overlay
|
||||
if not overlay_subapp:
|
||||
self.skipTest("No active overlay sub-application")
|
||||
|
||||
# Change a setting programmatically
|
||||
self.reader.increase_font_size()
|
||||
new_page = self.reader.get_current_page(include_highlights=False)
|
||||
|
||||
# Refresh overlay
|
||||
refreshed_image = overlay_subapp.refresh(
|
||||
updated_base_page=new_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
|
||||
)
|
||||
|
||||
self.assertIsNotNone(refreshed_image)
|
||||
self.assertEqual(refreshed_image.size, (800, 1200))
|
||||
|
||||
def test_line_spacing_actually_changes_rendering(self):
|
||||
"""Verify that line spacing changes produce different rendered images"""
|
||||
# Close any open overlay first
|
||||
if self.reader.is_overlay_open():
|
||||
self.reader.close_overlay()
|
||||
|
||||
# Set initial line spacing and get page
|
||||
self.reader.set_line_spacing(5)
|
||||
page1 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page1)
|
||||
|
||||
# Change line spacing significantly
|
||||
self.reader.set_line_spacing(30)
|
||||
page2 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page2)
|
||||
|
||||
# Images should be different (different line spacing should affect rendering)
|
||||
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
|
||||
"Line spacing change should affect rendering")
|
||||
|
||||
def test_inter_block_spacing_actually_changes_rendering(self):
|
||||
"""Verify that inter-block spacing changes produce different rendered images"""
|
||||
# Close any open overlay first
|
||||
if self.reader.is_overlay_open():
|
||||
self.reader.close_overlay()
|
||||
|
||||
# Set initial inter-block spacing and get page
|
||||
self.reader.set_inter_block_spacing(15)
|
||||
page1 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page1)
|
||||
|
||||
# Change inter-block spacing significantly
|
||||
self.reader.set_inter_block_spacing(50)
|
||||
page2 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page2)
|
||||
|
||||
# Images should be different
|
||||
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
|
||||
"Inter-block spacing change should affect rendering")
|
||||
|
||||
def test_word_spacing_actually_changes_rendering(self):
|
||||
"""Verify that word spacing changes produce different rendered images"""
|
||||
# Close any open overlay first
|
||||
if self.reader.is_overlay_open():
|
||||
self.reader.close_overlay()
|
||||
|
||||
# Set initial word spacing and get page
|
||||
self.reader.set_word_spacing(0)
|
||||
page1 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page1)
|
||||
|
||||
# Change word spacing significantly
|
||||
self.reader.set_word_spacing(20)
|
||||
page2 = self.reader.get_current_page()
|
||||
self.assertIsNotNone(page2)
|
||||
|
||||
# Images should be different
|
||||
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
|
||||
"Word spacing change should affect rendering")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -44,9 +44,14 @@ class TestTOCOverlay(unittest.TestCase):
|
||||
self.reader.close()
|
||||
|
||||
def test_overlay_manager_initialization(self):
|
||||
"""Test that overlay manager is properly initialized"""
|
||||
self.assertIsNotNone(self.reader.overlay_manager)
|
||||
self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200))
|
||||
"""Test that overlay sub-applications are properly initialized"""
|
||||
# Check that overlay sub-applications exist
|
||||
self.assertIsNotNone(self.reader._overlay_subapps)
|
||||
self.assertIn(OverlayState.TOC, self.reader._overlay_subapps)
|
||||
self.assertIn(OverlayState.SETTINGS, self.reader._overlay_subapps)
|
||||
self.assertIn(OverlayState.NAVIGATION, self.reader._overlay_subapps)
|
||||
|
||||
# Initially no overlay should be active
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
|
||||
|
||||
@@ -94,14 +99,14 @@ class TestTOCOverlay(unittest.TestCase):
|
||||
# Handle gesture
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should open overlay
|
||||
# Should open overlay (navigation or toc, depending on implementation)
|
||||
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
||||
self.assertEqual(response.data['overlay_type'], 'toc')
|
||||
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
def test_swipe_up_from_middle_does_not_open_toc(self):
|
||||
"""Test that swipe up from middle of screen does NOT open TOC"""
|
||||
# Create swipe up event from middle of screen (y=600, which is < 80% of 1200)
|
||||
def test_swipe_up_from_middle_opens_navigation(self):
|
||||
"""Test that swipe up from anywhere opens navigation overlay"""
|
||||
# Create swipe up event from middle of screen
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.SWIPE_UP,
|
||||
x=400,
|
||||
@@ -111,9 +116,10 @@ class TestTOCOverlay(unittest.TestCase):
|
||||
# Handle gesture
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# Should not open overlay
|
||||
self.assertEqual(response.action, ActionType.NONE)
|
||||
self.assertFalse(self.reader.is_overlay_open())
|
||||
# Should open navigation overlay from anywhere
|
||||
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
||||
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
|
||||
self.assertTrue(self.reader.is_overlay_open())
|
||||
|
||||
def test_swipe_down_closes_overlay(self):
|
||||
"""Test that swipe down closes the overlay"""
|
||||
@@ -166,9 +172,11 @@ class TestTOCOverlay(unittest.TestCase):
|
||||
self.skipTest("Need at least 2 chapters for this test")
|
||||
|
||||
# Calculate tap position for second chapter (index 1 - "Metamorphosis")
|
||||
# Based on actual measurements: chapter 1 link is at screen Y=282, X=200-300
|
||||
# Based on actual measurements from pyWebLayout query_point:
|
||||
# Overlay bounds: (38, 138, 122, 16) -> X=[38,160], Y=[138,154]
|
||||
# With panel offset (160, 180): Screen X=[198,320], Y=[318,334]
|
||||
tap_x = 250 # Within the link text bounds
|
||||
tap_y = 282 # Chapter 1 "Metamorphosis"
|
||||
tap_y = 335 # Chapter 1 "Metamorphosis" at overlay Y=155 (138+16=154, screen 180+155=335)
|
||||
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.TAP,
|
||||
@@ -295,13 +303,153 @@ class TestOverlayRendering(unittest.TestCase):
|
||||
self.assertIsNotNone(html)
|
||||
self.assertIn("Table of Contents", html)
|
||||
|
||||
# Render HTML to image using overlay manager
|
||||
overlay_manager = self.reader.overlay_manager
|
||||
image = overlay_manager.render_html_to_image(html)
|
||||
# Open the TOC overlay which internally renders HTML to image
|
||||
overlay_image = self.reader.open_toc_overlay()
|
||||
|
||||
# Should produce valid image
|
||||
self.assertIsNotNone(image)
|
||||
self.assertEqual(image.size, (800, 1200))
|
||||
self.assertIsNotNone(overlay_image)
|
||||
self.assertEqual(overlay_image.size, (800, 1200))
|
||||
|
||||
|
||||
class TestTOCPagination(unittest.TestCase):
|
||||
"""Test TOC overlay pagination functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test reader with a book"""
|
||||
self.reader = EbookReader(page_size=(800, 1200))
|
||||
|
||||
# Load a test EPUB
|
||||
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
|
||||
if not test_epub.exists():
|
||||
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
||||
epubs = list(epub_dir.glob('*.epub'))
|
||||
if epubs:
|
||||
test_epub = epubs[0]
|
||||
else:
|
||||
self.skipTest("No test EPUB files available")
|
||||
|
||||
success = self.reader.load_epub(str(test_epub))
|
||||
self.assertTrue(success, "Failed to load test EPUB")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up"""
|
||||
self.reader.close()
|
||||
|
||||
def test_pagination_with_many_chapters(self):
|
||||
"""Test pagination when there are more chapters than fit on one page"""
|
||||
from dreader.html_generator import generate_toc_overlay
|
||||
|
||||
# Create test data with many chapters
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
|
||||
|
||||
# Generate HTML for page 1 (chapters 0-9)
|
||||
html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
|
||||
self.assertIn("1. Chapter 1", html_page1)
|
||||
self.assertIn("10. Chapter 10", html_page1)
|
||||
self.assertNotIn("11. Chapter 11", html_page1)
|
||||
self.assertIn("Page 1 of 3", html_page1)
|
||||
|
||||
# Generate HTML for page 2 (chapters 10-19)
|
||||
html_page2 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=1, toc_items_per_page=10)
|
||||
self.assertNotIn("10. Chapter 10", html_page2)
|
||||
self.assertIn("11. Chapter 11", html_page2)
|
||||
self.assertIn("20. Chapter 20", html_page2)
|
||||
self.assertIn("Page 2 of 3", html_page2)
|
||||
|
||||
# Generate HTML for page 3 (chapters 20-24)
|
||||
html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10)
|
||||
self.assertNotIn("20. Chapter 20", html_page3)
|
||||
self.assertIn("21. Chapter 21", html_page3)
|
||||
self.assertIn("25. Chapter 25", html_page3)
|
||||
self.assertIn("Page 3 of 3", html_page3)
|
||||
|
||||
def test_pagination_buttons_disabled_at_boundaries(self):
|
||||
"""Test that pagination buttons are disabled at first and last pages"""
|
||||
from dreader.html_generator import generate_toc_overlay
|
||||
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
|
||||
|
||||
# Page 1: prev button should be disabled
|
||||
html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
|
||||
self.assertIn("page:prev", html_page1)
|
||||
self.assertIn("page:next", html_page1)
|
||||
# Check that prev button has disabled styling
|
||||
self.assertIn("opacity: 0.3; pointer-events: none;", html_page1)
|
||||
|
||||
# Last page: next button should be disabled
|
||||
html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10)
|
||||
self.assertIn("page:prev", html_page3)
|
||||
self.assertIn("page:next", html_page3)
|
||||
|
||||
def test_no_pagination_for_small_list(self):
|
||||
"""Test that pagination is not shown when all chapters fit on one page"""
|
||||
from dreader.html_generator import generate_toc_overlay
|
||||
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)]
|
||||
|
||||
html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
|
||||
self.assertNotIn("page:prev", html)
|
||||
self.assertNotIn("page:next", html)
|
||||
self.assertNotIn("Page", html.split("chapters")[1]) # No "Page X of Y" after "N chapters"
|
||||
|
||||
def test_navigation_overlay_pagination(self):
|
||||
"""Test pagination in the modern navigation overlay"""
|
||||
from dreader.html_generator import generate_navigation_overlay
|
||||
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
|
||||
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(15)]
|
||||
|
||||
# Generate navigation overlay with pagination
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="contents",
|
||||
page_size=(800, 1200),
|
||||
toc_page=1,
|
||||
toc_items_per_page=10,
|
||||
bookmarks_page=0
|
||||
)
|
||||
|
||||
# Should show chapters 11-20 on page 2
|
||||
self.assertIn("11. Chapter 11", html)
|
||||
self.assertIn("20. Chapter 20", html)
|
||||
self.assertNotIn("10. Chapter 10", html)
|
||||
self.assertNotIn("21. Chapter 21", html)
|
||||
|
||||
def test_bookmarks_pagination(self):
|
||||
"""Test pagination works for bookmarks tab too"""
|
||||
from dreader.html_generator import generate_navigation_overlay
|
||||
|
||||
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)]
|
||||
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(25)]
|
||||
|
||||
# Generate navigation overlay with bookmarks on page 2
|
||||
html = generate_navigation_overlay(
|
||||
chapters=chapters,
|
||||
bookmarks=bookmarks,
|
||||
active_tab="bookmarks",
|
||||
page_size=(800, 1200),
|
||||
toc_page=0,
|
||||
toc_items_per_page=10,
|
||||
bookmarks_page=1
|
||||
)
|
||||
|
||||
# Should show bookmarks 11-20 on page 2
|
||||
self.assertIn("Bookmark 11", html)
|
||||
self.assertIn("Bookmark 20", html)
|
||||
self.assertNotIn("Bookmark 10", html)
|
||||
self.assertNotIn("Bookmark 21", html)
|
||||
|
||||
def test_pagination_handles_empty_list(self):
|
||||
"""Test pagination handles empty chapter list gracefully"""
|
||||
from dreader.html_generator import generate_toc_overlay
|
||||
|
||||
chapters = []
|
||||
html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
|
||||
|
||||
self.assertIn("0 chapters", html)
|
||||
self.assertNotIn("page:prev", html)
|
||||
self.assertNotIn("page:next", html)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for dreader modules."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for manager modules."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Unit tests for DocumentManager.
|
||||
|
||||
Tests document loading in isolation without full EbookReader.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dreader.managers.document import DocumentManager
|
||||
|
||||
|
||||
class TestDocumentManager(unittest.TestCase):
|
||||
"""Test DocumentManager in isolation"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
self.manager = DocumentManager()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up"""
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test manager initializes correctly"""
|
||||
manager = DocumentManager()
|
||||
|
||||
self.assertIsNone(manager.document_id)
|
||||
self.assertIsNone(manager.title)
|
||||
self.assertIsNone(manager.author)
|
||||
self.assertIsNone(manager.blocks)
|
||||
self.assertFalse(manager.is_loaded())
|
||||
|
||||
def test_load_valid_epub(self):
|
||||
"""Test loading a valid EPUB file"""
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
success = self.manager.load_epub(self.epub_path)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(self.manager.is_loaded())
|
||||
self.assertIsNotNone(self.manager.document_id)
|
||||
self.assertIsNotNone(self.manager.title)
|
||||
self.assertIsNotNone(self.manager.author)
|
||||
self.assertIsNotNone(self.manager.blocks)
|
||||
self.assertGreater(len(self.manager.blocks), 0)
|
||||
|
||||
def test_load_nonexistent_epub(self):
|
||||
"""Test loading a non-existent EPUB file"""
|
||||
success = self.manager.load_epub("nonexistent.epub")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(self.manager.is_loaded())
|
||||
|
||||
def test_load_invalid_epub(self):
|
||||
"""Test loading an invalid file as EPUB"""
|
||||
# Create a temporary invalid file
|
||||
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
|
||||
with open(invalid_path, 'w') as f:
|
||||
f.write("This is not a valid EPUB file")
|
||||
|
||||
success = self.manager.load_epub(invalid_path)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(self.manager.is_loaded())
|
||||
|
||||
def test_load_html_success(self):
|
||||
"""Test loading HTML content"""
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<h1>Test Document</h1>
|
||||
<p>This is a test paragraph.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
success = self.manager.load_html(
|
||||
html,
|
||||
title="Test HTML",
|
||||
author="Test Author",
|
||||
document_id="test_html"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(self.manager.is_loaded())
|
||||
self.assertEqual(self.manager.title, "Test HTML")
|
||||
self.assertEqual(self.manager.author, "Test Author")
|
||||
self.assertEqual(self.manager.document_id, "test_html")
|
||||
self.assertGreater(len(self.manager.blocks), 0)
|
||||
|
||||
def test_load_empty_html(self):
|
||||
"""Test loading empty HTML"""
|
||||
success = self.manager.load_html("")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(self.manager.is_loaded())
|
||||
|
||||
def test_get_metadata(self):
|
||||
"""Test getting document metadata"""
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.manager.load_epub(self.epub_path)
|
||||
metadata = self.manager.get_metadata()
|
||||
|
||||
self.assertIsInstance(metadata, dict)
|
||||
self.assertIn('title', metadata)
|
||||
self.assertIn('author', metadata)
|
||||
self.assertIn('document_id', metadata)
|
||||
self.assertIn('total_blocks', metadata)
|
||||
self.assertGreater(metadata['total_blocks'], 0)
|
||||
|
||||
def test_get_blocks(self):
|
||||
"""Test getting content blocks"""
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.manager.load_epub(self.epub_path)
|
||||
blocks = self.manager.get_blocks()
|
||||
|
||||
self.assertIsInstance(blocks, list)
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
def test_clear(self):
|
||||
"""Test clearing loaded document"""
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.manager.load_epub(self.epub_path)
|
||||
self.assertTrue(self.manager.is_loaded())
|
||||
|
||||
self.manager.clear()
|
||||
|
||||
self.assertFalse(self.manager.is_loaded())
|
||||
self.assertIsNone(self.manager.document_id)
|
||||
self.assertIsNone(self.manager.title)
|
||||
self.assertIsNone(self.manager.author)
|
||||
self.assertIsNone(self.manager.blocks)
|
||||
|
||||
def test_multiple_loads(self):
|
||||
"""Test loading multiple documents sequentially"""
|
||||
html1 = "<html><body><h1>Document 1</h1></body></html>"
|
||||
html2 = "<html><body><h1>Document 2</h1></body></html>"
|
||||
|
||||
# Load first document
|
||||
self.manager.load_html(html1, title="Doc 1", document_id="doc1")
|
||||
self.assertEqual(self.manager.title, "Doc 1")
|
||||
self.assertEqual(self.manager.document_id, "doc1")
|
||||
|
||||
# Load second document (should replace first)
|
||||
self.manager.load_html(html2, title="Doc 2", document_id="doc2")
|
||||
self.assertEqual(self.manager.title, "Doc 2")
|
||||
self.assertEqual(self.manager.document_id, "doc2")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Unit tests for SettingsManager.
|
||||
|
||||
Tests settings management in isolation using mocks.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
|
||||
from dreader.managers.settings import SettingsManager
|
||||
|
||||
|
||||
class TestSettingsManager(unittest.TestCase):
|
||||
"""Test SettingsManager in isolation"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test manager initializes correctly"""
|
||||
manager = SettingsManager()
|
||||
|
||||
self.assertEqual(manager.font_scale, 1.0)
|
||||
self.assertEqual(manager.font_scale_step, 0.1)
|
||||
self.assertIsNone(manager.manager)
|
||||
|
||||
def test_get_font_size(self):
|
||||
"""Test getting current font size"""
|
||||
self.assertEqual(self.manager.get_font_size(), 1.0)
|
||||
|
||||
self.manager.font_scale = 1.5
|
||||
self.assertEqual(self.manager.get_font_size(), 1.5)
|
||||
|
||||
def test_set_font_size_without_manager(self):
|
||||
"""Test setting font size without layout manager returns None"""
|
||||
result = self.manager.set_font_size(1.5)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_set_font_size_with_manager(self):
|
||||
"""Test setting font size with layout manager"""
|
||||
# Create mock manager
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.set_font_scale.return_value = mock_page
|
||||
mock_layout_manager.font_scale = 1.0
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
# Set font size
|
||||
result = self.manager.set_font_size(1.5)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result, "rendered_page")
|
||||
self.assertEqual(self.manager.font_scale, 1.5)
|
||||
mock_layout_manager.set_font_scale.assert_called_once_with(1.5)
|
||||
|
||||
def test_font_size_clamping(self):
|
||||
"""Test font size is clamped between 0.5x and 3.0x"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.set_font_scale.return_value = mock_page
|
||||
mock_layout_manager.font_scale = 1.0
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
# Test upper bound
|
||||
self.manager.set_font_size(5.0)
|
||||
self.assertEqual(self.manager.font_scale, 3.0)
|
||||
|
||||
# Test lower bound
|
||||
self.manager.set_font_size(0.1)
|
||||
self.assertEqual(self.manager.font_scale, 0.5)
|
||||
|
||||
def test_increase_font_size(self):
|
||||
"""Test increasing font size by one step"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.set_font_scale.return_value = mock_page
|
||||
mock_layout_manager.font_scale = 1.0
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
initial_size = self.manager.font_scale
|
||||
self.manager.increase_font_size()
|
||||
|
||||
self.assertEqual(self.manager.font_scale, initial_size + 0.1)
|
||||
|
||||
def test_decrease_font_size(self):
|
||||
"""Test decreasing font size by one step"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.set_font_scale.return_value = mock_page
|
||||
mock_layout_manager.font_scale = 1.5
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
self.manager.font_scale = 1.5
|
||||
|
||||
self.manager.decrease_font_size()
|
||||
|
||||
self.assertAlmostEqual(self.manager.font_scale, 1.4, places=5)
|
||||
|
||||
def test_set_line_spacing(self):
|
||||
"""Test setting line spacing"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.get_current_page.return_value = mock_page
|
||||
mock_layout_manager.page_style = Mock()
|
||||
mock_layout_manager.page_style.line_spacing = 5
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
result = self.manager.set_line_spacing(10)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
mock_layout_manager.increase_line_spacing.assert_called_once_with(5)
|
||||
|
||||
def test_set_inter_block_spacing(self):
|
||||
"""Test setting inter-block spacing"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.get_current_page.return_value = mock_page
|
||||
mock_layout_manager.page_style = Mock()
|
||||
mock_layout_manager.page_style.inter_block_spacing = 15
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
result = self.manager.set_inter_block_spacing(25)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
mock_layout_manager.increase_inter_block_spacing.assert_called_once_with(10)
|
||||
|
||||
def test_set_word_spacing(self):
|
||||
"""Test setting word spacing"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.get_current_page.return_value = mock_page
|
||||
mock_layout_manager.page_style = Mock()
|
||||
mock_layout_manager.page_style.word_spacing = 0
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
result = self.manager.set_word_spacing(3)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
mock_layout_manager.increase_word_spacing.assert_called_once_with(3)
|
||||
|
||||
def test_get_current_settings_without_manager(self):
|
||||
"""Test getting settings without layout manager"""
|
||||
settings = self.manager.get_current_settings()
|
||||
|
||||
self.assertIsInstance(settings, dict)
|
||||
self.assertEqual(settings['font_scale'], 1.0)
|
||||
self.assertEqual(settings['line_spacing'], 5)
|
||||
self.assertEqual(settings['inter_block_spacing'], 15)
|
||||
self.assertEqual(settings['word_spacing'], 0)
|
||||
|
||||
def test_get_current_settings_with_manager(self):
|
||||
"""Test getting settings with layout manager"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_layout_manager.page_style = Mock()
|
||||
mock_layout_manager.page_style.line_spacing = 10
|
||||
mock_layout_manager.page_style.inter_block_spacing = 20
|
||||
mock_layout_manager.page_style.word_spacing = 3
|
||||
mock_layout_manager.font_scale = 1.5
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
self.manager.font_scale = 1.5
|
||||
|
||||
settings = self.manager.get_current_settings()
|
||||
|
||||
self.assertEqual(settings['font_scale'], 1.5)
|
||||
self.assertEqual(settings['line_spacing'], 10)
|
||||
self.assertEqual(settings['inter_block_spacing'], 20)
|
||||
self.assertEqual(settings['word_spacing'], 3)
|
||||
|
||||
def test_apply_settings(self):
|
||||
"""Test applying settings from dictionary"""
|
||||
mock_layout_manager = Mock()
|
||||
mock_page = Mock()
|
||||
mock_page.render.return_value = "rendered_page"
|
||||
mock_layout_manager.set_font_scale.return_value = mock_page
|
||||
mock_layout_manager.get_current_page.return_value = mock_page
|
||||
mock_layout_manager.page_style = Mock()
|
||||
mock_layout_manager.page_style.line_spacing = 5
|
||||
mock_layout_manager.page_style.inter_block_spacing = 15
|
||||
mock_layout_manager.page_style.word_spacing = 0
|
||||
mock_layout_manager.font_scale = 1.0
|
||||
|
||||
self.manager.set_manager(mock_layout_manager)
|
||||
|
||||
settings = {
|
||||
'font_scale': 1.5,
|
||||
'line_spacing': 10,
|
||||
'inter_block_spacing': 20,
|
||||
'word_spacing': 3
|
||||
}
|
||||
|
||||
success = self.manager.apply_settings(settings)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(self.manager.font_scale, 1.5)
|
||||
|
||||
def test_apply_settings_without_manager(self):
|
||||
"""Test applying settings without layout manager returns False"""
|
||||
settings = {'font_scale': 1.5}
|
||||
success = self.manager.apply_settings(settings)
|
||||
|
||||
self.assertFalse(success)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||