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 |
@@ -0,0 +1,2 @@
|
|||||||
|
# Mark EPUB files as binary to prevent any text transformations
|
||||||
|
*.epub binary
|
||||||
@@ -12,6 +12,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['3.12', '3.13']
|
||||||
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -19,19 +23,21 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
# Install package in development mode (force reinstall pyweblayout to get latest from master)
|
# Install package in development mode (force reinstall pyweblayout to get latest from master)
|
||||||
pip install --upgrade --force-reinstall --no-deps git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
|
pip install --upgrade --force-reinstall --no-deps --no-cache-dir git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
|
||||||
pip install -e .
|
pip install -e .
|
||||||
# Install test dependencies if they exist
|
# Install test dependencies if they exist
|
||||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
|
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
|
if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi
|
||||||
# Install common test packages
|
# Install common test packages
|
||||||
pip install pytest pytest-cov flake8 coverage-badge interrogate
|
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
|
- name: Download initial failed badges
|
||||||
run: |
|
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 |
|
|  | **Documentation Coverage** - Percentage of code with docstrings |
|
||||||
|  | **License** - Project licensing information |
|
|  | **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
|
## 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.
|
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
|
### Core Reading Features
|
||||||
- 📖 **EPUB Support** - Load and render EPUB files with full text extraction
|
- 📖 **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
|
- 📄 **Page Rendering** - Render pages as PIL Images optimized for any display
|
||||||
- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation
|
- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation
|
||||||
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
||||||
- 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC
|
- 📑 **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
|
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
||||||
|
|
||||||
### Text Interaction
|
### 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
|
- 💾 **Position Persistence** - Stable positions across style changes
|
||||||
- ⚡ **Smart Reflow** - Automatic text reflow on font/spacing changes
|
- ⚡ **Smart Reflow** - Automatic text reflow on font/spacing changes
|
||||||
- 🎨 **Custom Styling** - Full control over colors, fonts, and layout
|
- 🎨 **Custom Styling** - Full control over colors, fonts, and layout
|
||||||
|
- 💾 **Settings Persistence** - Save and restore preferences across sessions
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ reader.get_chapters() # List all chapters
|
|||||||
reader.get_current_chapter_info()
|
reader.get_current_chapter_info()
|
||||||
reader.get_reading_progress() # Returns 0.0 to 1.0
|
reader.get_reading_progress() # Returns 0.0 to 1.0
|
||||||
|
|
||||||
# TOC Overlay
|
# Navigation Overlay (unified TOC + Bookmarks)
|
||||||
overlay_image = reader.open_toc_overlay() # Returns composited image with TOC
|
overlay_image = reader.open_navigation_overlay() # Opens with tabs
|
||||||
reader.close_overlay()
|
reader.close_overlay()
|
||||||
reader.is_overlay_open()
|
reader.is_overlay_open()
|
||||||
```
|
```
|
||||||
@@ -236,34 +236,84 @@ elif response.action == ActionType.CHAPTER_SELECTED:
|
|||||||
# - TAP: Select words, activate links, navigate TOC
|
# - TAP: Select words, activate links, navigate TOC
|
||||||
# - LONG_PRESS: Show definitions or context menu
|
# - LONG_PRESS: Show definitions or context menu
|
||||||
# - SWIPE_LEFT/RIGHT: Page navigation
|
# - SWIPE_LEFT/RIGHT: Page navigation
|
||||||
# - SWIPE_UP: Open TOC overlay (from bottom 20% of screen)
|
# - SWIPE_UP: Open navigation overlay (from bottom 20% of screen)
|
||||||
# - SWIPE_DOWN: Close overlay
|
# - SWIPE_DOWN: Close overlay or open settings (from top 20%)
|
||||||
# - PINCH_IN/OUT: Font size adjustment
|
# - PINCH_IN/OUT: Font size adjustment
|
||||||
# - DRAG: Text selection
|
# - DRAG: Text selection
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Operations
|
### Settings Persistence
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Save current page to file
|
from dreader.state import StateManager
|
||||||
reader.render_to_file("current_page.png")
|
from pathlib import Path
|
||||||
|
|
||||||
# Context manager (auto-saves position on close)
|
# Initialize state manager
|
||||||
with EbookReader(page_size=(800, 1000)) as reader:
|
state_file = Path.home() / ".config" / "dreader" / "state.json"
|
||||||
reader.load_epub("book.epub")
|
state_manager = StateManager(state_file=state_file)
|
||||||
# ... use reader ...
|
|
||||||
# Position automatically saved on exit
|
# 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
|
## 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
|
- **[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
|
- **[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
|
- **[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_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
|
## Architecture
|
||||||
|
|
||||||
@@ -279,6 +329,27 @@ dreader.application.EbookReader (High-Level API)
|
|||||||
└── pyWebLayout.io.readers.epub_reader # EPUB parsing
|
└── 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
|
### Relationship to pyWebLayout
|
||||||
|
|
||||||
**pyWebLayout** is a layout engine library providing low-level primitives:
|
**pyWebLayout** is a layout engine library providing low-level primitives:
|
||||||
@@ -297,6 +368,30 @@ Think of it like this:
|
|||||||
- **pyWebLayout** = React (library)
|
- **pyWebLayout** = React (library)
|
||||||
- **DReader Application** = Next.js (framework)
|
- **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
|
## Use Cases
|
||||||
|
|
||||||
- 📱 **Desktop Ereader Applications** - Build native ereader apps with Python
|
- 📱 **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
|
# Run comprehensive demo
|
||||||
python ereader_demo.py /path/to/book.epub
|
python ereader_demo.py /path/to/book.epub
|
||||||
|
|
||||||
|
# Run library integration demo
|
||||||
|
python library_reading_integration.py /path/to/library/
|
||||||
|
|
||||||
# Generate animated GIFs
|
# Generate animated GIFs
|
||||||
python generate_ereader_gifs.py /path/to/book.epub
|
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
|
- **Application API** - All EbookReader methods and workflows
|
||||||
- **System Integration** - Layout manager, bookmarks, and state management
|
- **System Integration** - Layout manager, bookmarks, and state management
|
||||||
- **Highlighting** - Word and selection highlighting with persistence
|
- **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
|
- **Edge Cases** - Error handling, boundary conditions, and recovery
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -367,6 +469,79 @@ pytest -v
|
|||||||
pytest --cov=dreader --cov-report=term-missing
|
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
|
## 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!
|
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 |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 1.9 MiB 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
|
OverlayState
|
||||||
)
|
)
|
||||||
from dreader.library import LibraryManager
|
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"
|
__version__ = "0.1.0"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -54,6 +55,12 @@ __all__ = [
|
|||||||
# Library
|
# Library
|
||||||
"LibraryManager",
|
"LibraryManager",
|
||||||
|
|
||||||
# Overlay
|
# Main application
|
||||||
"OverlayManager",
|
"DReaderApplication",
|
||||||
|
"AppConfig",
|
||||||
|
|
||||||
|
# HAL interfaces
|
||||||
|
"DisplayHAL",
|
||||||
|
"KeyboardInputHAL",
|
||||||
|
"EventLoopHAL",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ from pyWebLayout.layout.ereader_layout import RenderingPosition
|
|||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
from pyWebLayout.concrete.page import Page
|
from pyWebLayout.concrete.page import Page
|
||||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||||
from pyWebLayout.core.highlight import Highlight, HighlightColor
|
from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlight_from_query_result
|
||||||
|
|
||||||
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||||
from .state import OverlayState
|
from .state import OverlayState
|
||||||
from .overlay import OverlayManager
|
|
||||||
from .managers import DocumentManager, SettingsManager, HighlightCoordinator
|
from .managers import DocumentManager, SettingsManager, HighlightCoordinator
|
||||||
from .handlers import GestureRouter
|
from .handlers import GestureRouter
|
||||||
|
from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay
|
||||||
|
|
||||||
|
|
||||||
class EbookReader:
|
class EbookReader:
|
||||||
@@ -103,7 +103,7 @@ class EbookReader:
|
|||||||
self.page_style = PageStyle(
|
self.page_style = PageStyle(
|
||||||
background_color=background_color,
|
background_color=background_color,
|
||||||
border_width=margin,
|
border_width=margin,
|
||||||
border_color=(200, 200, 200),
|
border_color=background_color,
|
||||||
padding=(10, 10, 10, 10),
|
padding=(10, 10, 10, 10),
|
||||||
line_spacing=line_spacing,
|
line_spacing=line_spacing,
|
||||||
inter_block_spacing=inter_block_spacing
|
inter_block_spacing=inter_block_spacing
|
||||||
@@ -129,8 +129,13 @@ class EbookReader:
|
|||||||
self.base_font_scale = 1.0
|
self.base_font_scale = 1.0
|
||||||
self.font_scale_step = 0.1
|
self.font_scale_step = 0.1
|
||||||
|
|
||||||
# Overlay management
|
# Overlay sub-applications
|
||||||
self.overlay_manager = OverlayManager(page_size=page_size)
|
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
|
self.current_overlay_state = OverlayState.NONE
|
||||||
|
|
||||||
def load_epub(self, epub_path: str) -> bool:
|
def load_epub(self, epub_path: str) -> bool:
|
||||||
@@ -250,13 +255,13 @@ class EbookReader:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# If an overlay is open, return the cached composited overlay image
|
# If an overlay is open, return the cached composited overlay image
|
||||||
if self.is_overlay_open() and self.overlay_manager._cached_base_page:
|
if self.is_overlay_open() and self._active_overlay:
|
||||||
# Return the last composited overlay image
|
# Return the composited overlay from the sub-application
|
||||||
# The overlay manager keeps this updated when settings change
|
if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image:
|
||||||
return self.overlay_manager.composite_overlay(
|
return self._active_overlay.composite_overlay(
|
||||||
self.overlay_manager._cached_base_page,
|
self._active_overlay._cached_base_page,
|
||||||
self.overlay_manager._cached_overlay_image
|
self._active_overlay._cached_overlay_image
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = self.manager.get_current_page()
|
page = self.manager.get_current_page()
|
||||||
@@ -502,7 +507,28 @@ class EbookReader:
|
|||||||
Current font scale factor
|
Current font scale factor
|
||||||
"""
|
"""
|
||||||
return self.settings_manager.get_font_size()
|
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]:
|
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Set line spacing using pyWebLayout's native support.
|
Set line spacing using pyWebLayout's native support.
|
||||||
@@ -702,132 +728,26 @@ class EbookReader:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
|
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
|
||||||
"""Handle tap when overlay is open - select chapter, adjust settings, or close overlay"""
|
"""
|
||||||
# For TOC overlay, use pyWebLayout link query to detect chapter clicks
|
Handle tap when overlay is open.
|
||||||
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)
|
|
||||||
|
|
||||||
# If query failed (tap outside overlay), close it
|
Delegates to the active overlay sub-application for handling.
|
||||||
if not query_result:
|
If the response indicates the overlay should be closed, closes it.
|
||||||
self.close_overlay()
|
"""
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
if not self._active_overlay:
|
||||||
|
# No active overlay, close legacy overlay if any
|
||||||
# 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
|
|
||||||
self.close_overlay()
|
self.close_overlay()
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
# For settings overlay, handle setting adjustments
|
# Delegate to the active overlay sub-application
|
||||||
elif self.current_overlay_state == OverlayState.SETTINGS:
|
response = self._active_overlay.handle_tap(x, y)
|
||||||
# Query the overlay to see what was tapped
|
|
||||||
query_result = self.overlay_manager.query_overlay_pixel(x, y)
|
|
||||||
|
|
||||||
# If query failed (tap outside overlay), close it
|
# If the response indicates overlay should be closed, close it
|
||||||
if not query_result:
|
if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED,
|
||||||
self.close_overlay()
|
ActionType.BOOKMARK_SELECTED):
|
||||||
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"]
|
|
||||||
|
|
||||||
# Parse "setting:action" format
|
|
||||||
if link_target.startswith("setting:"):
|
|
||||||
action = link_target.split(":", 1)[1]
|
|
||||||
|
|
||||||
# Apply the setting change
|
|
||||||
if action == "font_increase":
|
|
||||||
self.increase_font_size()
|
|
||||||
elif action == "font_decrease":
|
|
||||||
self.decrease_font_size()
|
|
||||||
elif action == "line_spacing_increase":
|
|
||||||
new_spacing = self.page_style.line_spacing + 2
|
|
||||||
self.set_line_spacing(new_spacing)
|
|
||||||
elif action == "line_spacing_decrease":
|
|
||||||
new_spacing = max(0, self.page_style.line_spacing - 2)
|
|
||||||
self.set_line_spacing(new_spacing)
|
|
||||||
elif action == "block_spacing_increase":
|
|
||||||
new_spacing = self.page_style.inter_block_spacing + 3
|
|
||||||
self.set_inter_block_spacing(new_spacing)
|
|
||||||
elif action == "block_spacing_decrease":
|
|
||||||
new_spacing = max(0, self.page_style.inter_block_spacing - 3)
|
|
||||||
self.set_inter_block_spacing(new_spacing)
|
|
||||||
elif action == "word_spacing_increase":
|
|
||||||
new_spacing = self.page_style.word_spacing + 2
|
|
||||||
self.set_word_spacing(new_spacing)
|
|
||||||
elif action == "word_spacing_decrease":
|
|
||||||
new_spacing = max(0, self.page_style.word_spacing - 2)
|
|
||||||
self.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.manager.get_current_page()
|
|
||||||
updated_page = page.render()
|
|
||||||
|
|
||||||
# Refresh the settings overlay with updated values and page
|
|
||||||
self.overlay_manager.refresh_settings_overlay(
|
|
||||||
updated_base_page=updated_page,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
return GestureResponse(ActionType.SETTING_CHANGED, {
|
|
||||||
"action": action,
|
|
||||||
"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
|
|
||||||
})
|
|
||||||
|
|
||||||
# Parse "action:command" format for other actions
|
|
||||||
elif link_target.startswith("action:"):
|
|
||||||
action = link_target.split(":", 1)[1]
|
|
||||||
|
|
||||||
if action == "back_to_library":
|
|
||||||
# Close the overlay first
|
|
||||||
self.close_overlay()
|
|
||||||
# Return a special action for the application to handle
|
|
||||||
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
|
|
||||||
|
|
||||||
# Not a setting control, close overlay
|
|
||||||
self.close_overlay()
|
self.close_overlay()
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
|
||||||
|
|
||||||
# For other overlays, just close on any tap for now
|
return response
|
||||||
self.close_overlay()
|
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -1060,8 +980,12 @@ class EbookReader:
|
|||||||
# Get chapters
|
# Get chapters
|
||||||
chapters = self.get_chapters()
|
chapters = self.get_chapters()
|
||||||
|
|
||||||
# Open overlay and get composited image
|
# Use the TOC sub-application
|
||||||
result = self.overlay_manager.open_toc_overlay(chapters, base_page)
|
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
|
self.current_overlay_state = OverlayState.TOC
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1086,15 +1010,22 @@ class EbookReader:
|
|||||||
line_spacing = self.page_style.line_spacing
|
line_spacing = self.page_style.line_spacing
|
||||||
inter_block_spacing = self.page_style.inter_block_spacing
|
inter_block_spacing = self.page_style.inter_block_spacing
|
||||||
word_spacing = self.page_style.word_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"
|
||||||
|
|
||||||
# Open overlay and get composited image
|
# Use the Settings sub-application
|
||||||
result = self.overlay_manager.open_settings_overlay(
|
overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS]
|
||||||
|
result = overlay_subapp.open(
|
||||||
base_page,
|
base_page,
|
||||||
font_scale=font_scale,
|
font_scale=font_scale,
|
||||||
line_spacing=line_spacing,
|
line_spacing=line_spacing,
|
||||||
inter_block_spacing=inter_block_spacing,
|
inter_block_spacing=inter_block_spacing,
|
||||||
word_spacing=word_spacing
|
word_spacing=word_spacing,
|
||||||
|
font_family=font_family_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self._active_overlay = overlay_subapp
|
||||||
self.current_overlay_state = OverlayState.SETTINGS
|
self.current_overlay_state = OverlayState.SETTINGS
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1103,9 +1034,26 @@ class EbookReader:
|
|||||||
"""
|
"""
|
||||||
Open the bookmarks overlay.
|
Open the bookmarks overlay.
|
||||||
|
|
||||||
|
This is a convenience method that opens the navigation overlay with the bookmarks tab active.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Composited image with bookmarks overlay on top of current page, or None if no book loaded
|
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():
|
if not self.is_loaded():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1114,19 +1062,51 @@ class EbookReader:
|
|||||||
if not base_page:
|
if not base_page:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get bookmarks
|
# Get chapters for Contents tab
|
||||||
|
chapters = self.get_chapters()
|
||||||
|
|
||||||
|
# Get bookmarks for Bookmarks tab
|
||||||
bookmark_names = self.list_saved_positions()
|
bookmark_names = self.list_saved_positions()
|
||||||
bookmarks = [
|
bookmarks = [
|
||||||
{"name": name, "position": f"Saved position"}
|
{"name": name, "position": f"Saved position"}
|
||||||
for name in bookmark_names
|
for name in bookmark_names
|
||||||
]
|
]
|
||||||
|
|
||||||
# Open overlay and get composited image
|
# Use the Navigation sub-application
|
||||||
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
|
overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION]
|
||||||
self.current_overlay_state = OverlayState.BOOKMARKS
|
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
|
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]:
|
def close_overlay(self) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Close the current overlay and return to reading view.
|
Close the current overlay and return to reading view.
|
||||||
@@ -1137,7 +1117,12 @@ class EbookReader:
|
|||||||
if self.current_overlay_state == OverlayState.NONE:
|
if self.current_overlay_state == OverlayState.NONE:
|
||||||
return 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
|
self.current_overlay_state = OverlayState.NONE
|
||||||
|
|
||||||
# Return fresh current page
|
# Return fresh current page
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Utilities for managing book library, scanning EPUBs, and extracting metadata.
|
Utilities for managing book library, scanning EPUBs, and extracting metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from dreader import create_ebook_reader
|
from dreader import create_ebook_reader
|
||||||
@@ -11,6 +13,8 @@ from PIL import Image
|
|||||||
import ebooklib
|
import ebooklib
|
||||||
from ebooklib import epub
|
from ebooklib import epub
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
|
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:
|
Returns:
|
||||||
Dictionary with book metadata or None if extraction fails
|
Dictionary with book metadata or None if extraction fails
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
# Create temporary reader to extract metadata
|
# Create temporary reader to extract metadata
|
||||||
|
reader_start = time.time()
|
||||||
reader = create_ebook_reader(page_size=(400, 600))
|
reader = create_ebook_reader(page_size=(400, 600))
|
||||||
reader.load_epub(str(epub_path))
|
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 = {
|
metadata = {
|
||||||
'filename': epub_path.name,
|
'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
|
# Extract cover image if requested - use direct EPUB extraction
|
||||||
if include_cover:
|
if include_cover:
|
||||||
|
cover_start = time.time()
|
||||||
cover_data = extract_cover_from_epub(epub_path)
|
cover_data = extract_cover_from_epub(epub_path)
|
||||||
|
cover_elapsed = time.time() - cover_start
|
||||||
metadata['cover_data'] = cover_data
|
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
|
return metadata
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting metadata from {epub_path}: {e}")
|
||||||
print(f"Error extracting metadata from {epub_path}: {e}")
|
print(f"Error extracting metadata from {epub_path}: {e}")
|
||||||
return {
|
return {
|
||||||
'filename': epub_path.name,
|
'filename': epub_path.name,
|
||||||
@@ -128,15 +144,20 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Read the EPUB
|
# Read the EPUB
|
||||||
|
read_start = time.time()
|
||||||
book = epub.read_epub(str(epub_path))
|
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
|
# Look for cover image
|
||||||
cover_image = None
|
cover_image = None
|
||||||
|
search_start = time.time()
|
||||||
|
|
||||||
# First, try to find item marked as cover
|
# First, try to find item marked as cover
|
||||||
for item in book.get_items():
|
for item in book.get_items():
|
||||||
if item.get_type() == ebooklib.ITEM_COVER:
|
if item.get_type() == ebooklib.ITEM_COVER:
|
||||||
cover_image = Image.open(BytesIO(item.get_content()))
|
cover_image = Image.open(BytesIO(item.get_content()))
|
||||||
|
logger.debug(f"[COVER] Found cover marked as ITEM_COVER in {epub_path.name}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# If not found, look for files with 'cover' in the name
|
# 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()
|
name = item.get_name().lower()
|
||||||
if 'cover' in name:
|
if 'cover' in name:
|
||||||
cover_image = Image.open(BytesIO(item.get_content()))
|
cover_image = Image.open(BytesIO(item.get_content()))
|
||||||
|
logger.debug(f"[COVER] Found cover by filename in {epub_path.name}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# If still not found, get the first image
|
# 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:
|
if item.get_type() == ebooklib.ITEM_IMAGE:
|
||||||
try:
|
try:
|
||||||
cover_image = Image.open(BytesIO(item.get_content()))
|
cover_image = Image.open(BytesIO(item.get_content()))
|
||||||
|
logger.debug(f"[COVER] Using first image as cover in {epub_path.name}")
|
||||||
break
|
break
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
search_elapsed = time.time() - search_start
|
||||||
|
|
||||||
if not cover_image:
|
if not cover_image:
|
||||||
|
logger.debug(f"[COVER] No cover image found in {epub_path.name}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Resize if needed (maintain aspect ratio)
|
# Resize if needed (maintain aspect ratio)
|
||||||
|
process_start = time.time()
|
||||||
if cover_image.width > max_width or cover_image.height > max_height:
|
if cover_image.width > max_width or cover_image.height > max_height:
|
||||||
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
|
logger.debug(f"[COVER] Resized cover for {epub_path.name}")
|
||||||
|
|
||||||
# Convert to base64
|
# Convert to base64
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
cover_image.save(buffer, format='PNG')
|
cover_image.save(buffer, format='PNG')
|
||||||
img_bytes = buffer.getvalue()
|
img_bytes = buffer.getvalue()
|
||||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
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
|
return img_base64
|
||||||
|
|
||||||
except Exception as e:
|
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}")
|
print(f"Error extracting cover from EPUB {epub_path}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class GestureType(Enum):
|
|||||||
DRAG_MOVE = "drag_move" # Continue dragging
|
DRAG_MOVE = "drag_move" # Continue dragging
|
||||||
DRAG_END = "drag_end" # End dragging/selection
|
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
|
@dataclass
|
||||||
class TouchEvent:
|
class TouchEvent:
|
||||||
@@ -125,5 +129,7 @@ class ActionType:
|
|||||||
OVERLAY_OPENED = "overlay_opened"
|
OVERLAY_OPENED = "overlay_opened"
|
||||||
OVERLAY_CLOSED = "overlay_closed"
|
OVERLAY_CLOSED = "overlay_closed"
|
||||||
CHAPTER_SELECTED = "chapter_selected"
|
CHAPTER_SELECTED = "chapter_selected"
|
||||||
|
BOOKMARK_SELECTED = "bookmark_selected"
|
||||||
|
TAB_SWITCHED = "tab_switched"
|
||||||
SETTING_CHANGED = "setting_changed"
|
SETTING_CHANGED = "setting_changed"
|
||||||
BACK_TO_LIBRARY = "back_to_library"
|
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()
|
||||||
@@ -81,6 +81,10 @@ class GestureRouter:
|
|||||||
return self._handle_selection_move(event.x, event.y)
|
return self._handle_selection_move(event.x, event.y)
|
||||||
elif event.gesture == GestureType.DRAG_END:
|
elif event.gesture == GestureType.DRAG_END:
|
||||||
return self._handle_selection_end(event.x, event.y)
|
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, {})
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
@@ -234,36 +238,33 @@ class GestureRouter:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||||
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
|
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
|
||||||
# Check if swipe started from bottom 20% of screen
|
# Open navigation overlay from anywhere on screen
|
||||||
bottom_threshold = self.reader.page_size[1] * 0.8
|
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
|
||||||
|
if overlay_image:
|
||||||
if y >= bottom_threshold:
|
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||||
# Open TOC overlay
|
"overlay_type": "navigation",
|
||||||
overlay_image = self.reader.open_toc_overlay()
|
"active_tab": "contents",
|
||||||
if overlay_image:
|
"chapters": self.reader.get_chapters()
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
})
|
||||||
"overlay_type": "toc",
|
|
||||||
"chapters": self.reader.get_chapters()
|
|
||||||
})
|
|
||||||
|
|
||||||
return GestureResponse(ActionType.NONE, {})
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
||||||
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
|
"""Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)"""
|
||||||
# Check if swipe started 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
|
top_threshold = self.reader.page_size[1] * 0.2
|
||||||
|
if y > top_threshold:
|
||||||
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
if y <= top_threshold:
|
overlay_image = self.reader.open_settings_overlay()
|
||||||
# Open settings overlay
|
if overlay_image:
|
||||||
overlay_image = self.reader.open_settings_overlay()
|
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||||
if overlay_image:
|
"overlay_type": "settings",
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
"font_scale": self.reader.base_font_scale,
|
||||||
"overlay_type": "settings",
|
"line_spacing": self.reader.page_style.line_spacing,
|
||||||
"font_scale": self.reader.base_font_scale,
|
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
||||||
"line_spacing": self.reader.page_style.line_spacing,
|
})
|
||||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
|
||||||
})
|
|
||||||
|
|
||||||
return GestureResponse(ActionType.NONE, {})
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ def generate_settings_overlay(
|
|||||||
line_spacing: int = 5,
|
line_spacing: int = 5,
|
||||||
inter_block_spacing: int = 15,
|
inter_block_spacing: int = 15,
|
||||||
word_spacing: int = 0,
|
word_spacing: int = 0,
|
||||||
|
font_family: str = "Default",
|
||||||
page_size: tuple = (800, 1200)
|
page_size: tuple = (800, 1200)
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -210,6 +211,7 @@ def generate_settings_overlay(
|
|||||||
line_spacing: Current line spacing in pixels
|
line_spacing: Current line spacing in pixels
|
||||||
inter_block_spacing: Current inter-block spacing in pixels
|
inter_block_spacing: Current inter-block spacing in pixels
|
||||||
word_spacing: Current word 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
|
page_size: Page dimensions (width, height) for sizing the overlay
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -218,6 +220,15 @@ def generate_settings_overlay(
|
|||||||
# Format current values for display
|
# Format current values for display
|
||||||
font_percent = int(font_scale * 100)
|
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'''
|
html = f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -237,50 +248,66 @@ def generate_settings_overlay(
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="margin: 15px 0;">
|
<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;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
|
||||||
<b>Font Size: {font_percent}%</b>
|
<b>Font Size: {font_percent}%</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
|
||||||
<b>Line Spacing: {line_spacing}px</b>
|
<b>Line Spacing: {line_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
|
||||||
<b>Paragraph Spacing: {inter_block_spacing}px</b>
|
<b>Paragraph Spacing: {inter_block_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
|
||||||
<b>Word Spacing: {word_spacing}px</b>
|
<b>Word Spacing: {word_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin: 20px 0;">
|
<div style="margin: 20px 0;">
|
||||||
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
<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;">◄ Back to Library</a>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,7 +321,12 @@ def generate_settings_overlay(
|
|||||||
return 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.
|
Generate HTML for the table of contents overlay.
|
||||||
|
|
||||||
@@ -303,21 +335,32 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
|||||||
- index: Chapter index
|
- index: Chapter index
|
||||||
- title: Chapter title
|
- title: Chapter title
|
||||||
page_size: Page dimensions (width, height) for sizing the overlay
|
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:
|
Returns:
|
||||||
HTML string for TOC overlay (60% popup with transparent background)
|
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
|
# Build chapter list items with clickable links for pyWebLayout query
|
||||||
chapter_items = []
|
chapter_items = []
|
||||||
for i, chapter in enumerate(chapters):
|
for i, chapter in enumerate(toc_paginated):
|
||||||
title = chapter["title"]
|
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
|
# 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
|
# 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:
|
if len(title) <= 2:
|
||||||
# Add extra padding spaces inside the link to make it easier to click
|
# 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(
|
chapter_items.append(
|
||||||
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||||
@@ -326,6 +369,26 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
|||||||
f'{link_text}</a></p>'
|
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
|
# Render simple white panel - compositing will be done by OverlayManager
|
||||||
html = f'''
|
html = f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -345,10 +408,12 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
|||||||
{len(chapters)} chapters
|
{len(chapters)} chapters
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="max-height: 600px; overflow-y: auto;">
|
<div style="min-height: 400px;">
|
||||||
{"".join(chapter_items)}
|
{"".join(chapter_items)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{toc_pagination}
|
||||||
|
|
||||||
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
|
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
|
||||||
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
|
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
|
||||||
Tap a chapter to navigate • Tap outside to close
|
Tap a chapter to navigate • Tap outside to close
|
||||||
@@ -502,3 +567,197 @@ def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
|
|||||||
</html>
|
</html>
|
||||||
'''
|
'''
|
||||||
return 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
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Tuple
|
from typing import List, Dict, Optional, Tuple
|
||||||
from PIL import Image, ImageDraw
|
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 .book_utils import scan_book_directory, extract_book_metadata
|
||||||
from .state import LibraryState
|
from .state import LibraryState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LibraryManager:
|
class LibraryManager:
|
||||||
"""
|
"""
|
||||||
@@ -45,7 +49,8 @@ class LibraryManager:
|
|||||||
self,
|
self,
|
||||||
library_path: str,
|
library_path: str,
|
||||||
cache_dir: Optional[str] = None,
|
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.
|
Initialize library manager.
|
||||||
@@ -54,9 +59,11 @@ class LibraryManager:
|
|||||||
library_path: Path to directory containing EPUB files
|
library_path: Path to directory containing EPUB files
|
||||||
cache_dir: Optional cache directory for covers. If None, uses default.
|
cache_dir: Optional cache directory for covers. If None, uses default.
|
||||||
page_size: Page size for library view rendering
|
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.library_path = Path(library_path)
|
||||||
self.page_size = page_size
|
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
|
# Set cache directory
|
||||||
if cache_dir:
|
if cache_dir:
|
||||||
@@ -75,6 +82,7 @@ class LibraryManager:
|
|||||||
self.temp_cover_files: List[str] = [] # Track temp files for cleanup
|
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.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.table_renderer: Optional[TableRenderer] = None # Store renderer for bounds info
|
||||||
|
self.current_page: int = 0 # Current page index for pagination
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_default_cache_dir() -> Path:
|
def _get_default_cache_dir() -> Path:
|
||||||
@@ -96,19 +104,34 @@ class LibraryManager:
|
|||||||
Returns:
|
Returns:
|
||||||
List of book dictionaries with metadata
|
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}")
|
print(f"Scanning library: {self.library_path}")
|
||||||
|
|
||||||
if not self.library_path.exists():
|
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}")
|
print(f"Library path does not exist: {self.library_path}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Scan directory
|
# Scan directory
|
||||||
|
scan_start = time.time()
|
||||||
self.books = scan_book_directory(self.library_path)
|
self.books = scan_book_directory(self.library_path)
|
||||||
|
scan_elapsed = time.time() - scan_start
|
||||||
|
logger.info(f"[LIBRARY] Directory scan completed in {scan_elapsed:.2f}s - found {len(self.books)} books")
|
||||||
|
|
||||||
# Cache covers to disk if not already cached
|
# Cache 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)
|
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")
|
print(f"Found {len(self.books)} books in library")
|
||||||
return self.books
|
return self.books
|
||||||
|
|
||||||
@@ -149,12 +172,13 @@ class LibraryManager:
|
|||||||
print(f"Error caching cover for {book['title']}: {e}")
|
print(f"Error caching cover for {book['title']}: {e}")
|
||||||
return None
|
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:
|
Args:
|
||||||
books: List of books to display. If None, uses self.books
|
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:
|
Returns:
|
||||||
Table object ready for rendering
|
Table object ready for rendering
|
||||||
@@ -162,83 +186,133 @@ class LibraryManager:
|
|||||||
if books is None:
|
if books is None:
|
||||||
books = self.books
|
books = self.books
|
||||||
|
|
||||||
|
if page is None:
|
||||||
|
page = self.current_page
|
||||||
|
|
||||||
if not books:
|
if not books:
|
||||||
print("No books to display in library")
|
print("No books to display in library")
|
||||||
books = []
|
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
|
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
|
||||||
table = Table(caption="My Library", style=Font(font_size=18, weight="bold"))
|
|
||||||
|
|
||||||
# Add books as rows
|
# Create table with caption showing page info
|
||||||
for i, book in enumerate(books):
|
caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library"
|
||||||
row = table.create_row("body")
|
table = Table(caption=caption_text, style=Font(font_size=18, weight="bold"))
|
||||||
|
|
||||||
# Cover cell with interactive image
|
# Add books in 2-column grid (each pair of books gets 2 rows: covers then details)
|
||||||
cover_cell = row.create_cell()
|
for i in range(0, len(page_books), 2):
|
||||||
cover_path = book.get('cover_path')
|
# Row 1: Covers for this pair
|
||||||
book_path = book['path']
|
cover_row = table.create_row("body")
|
||||||
|
|
||||||
# Create callback that returns book path
|
# Add first book's cover (left column)
|
||||||
callback = lambda point, path=book_path: path
|
self._add_book_cover(cover_row, page_books[i])
|
||||||
|
|
||||||
if cover_path and Path(cover_path).exists():
|
# Add second book's cover (right column) if it exists
|
||||||
# Use cached cover with callback
|
if i + 1 < len(page_books):
|
||||||
img = InteractiveImage.create_and_add_to(
|
self._add_book_cover(cover_row, page_books[i + 1])
|
||||||
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:
|
else:
|
||||||
# No cover available
|
# Add empty cell if odd number of books
|
||||||
self._add_no_cover_text(cover_cell)
|
cover_row.create_cell()
|
||||||
|
|
||||||
# Book info cell
|
# Row 2: Details for this pair
|
||||||
info_cell = row.create_cell()
|
details_row = table.create_row("body")
|
||||||
|
|
||||||
# Title paragraph
|
# Add first book's details (left column)
|
||||||
title_para = info_cell.create_paragraph()
|
self._add_book_details(details_row, page_books[i])
|
||||||
for word in book['title'].split():
|
|
||||||
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
|
||||||
|
|
||||||
# Author paragraph
|
# Add second book's details (right column) if it exists
|
||||||
author_para = info_cell.create_paragraph()
|
if i + 1 < len(page_books):
|
||||||
for word in book.get('author', 'Unknown').split():
|
self._add_book_details(details_row, page_books[i + 1])
|
||||||
author_para.add_word(Word(word, Font(font_size=12)))
|
else:
|
||||||
|
# Add empty cell if odd number of books
|
||||||
# Filename paragraph (small, gray)
|
details_row.create_cell()
|
||||||
filename_para = info_cell.create_paragraph()
|
|
||||||
filename_para.add_word(Word(
|
|
||||||
Path(book['path']).name,
|
|
||||||
Font(font_size=10, colour=(150, 150, 150))
|
|
||||||
))
|
|
||||||
|
|
||||||
self.library_table = table
|
self.library_table = table
|
||||||
return 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):
|
def _add_no_cover_text(self, cell):
|
||||||
"""Add placeholder text when no cover is available"""
|
"""Add placeholder text when no cover is available"""
|
||||||
para = cell.create_paragraph()
|
para = cell.create_paragraph()
|
||||||
@@ -255,15 +329,20 @@ class LibraryManager:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image of the rendered library
|
PIL Image of the rendered library
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
if table is None:
|
if table is None:
|
||||||
if self.library_table is None:
|
if self.library_table is None:
|
||||||
print("No table to render, creating one first...")
|
print("No table to render, creating one first...")
|
||||||
|
logger.info("[LIBRARY] Creating library table...")
|
||||||
self.create_library_table()
|
self.create_library_table()
|
||||||
table = self.library_table
|
table = self.library_table
|
||||||
|
|
||||||
print("Rendering library table...")
|
print("Rendering library table...")
|
||||||
|
logger.info("[LIBRARY] Rendering library table...")
|
||||||
|
|
||||||
# Create page
|
# Create page
|
||||||
|
page_start = time.time()
|
||||||
page_style = PageStyle(
|
page_style = PageStyle(
|
||||||
border_width=0,
|
border_width=0,
|
||||||
padding=(30, 30, 30, 30),
|
padding=(30, 30, 30, 30),
|
||||||
@@ -273,6 +352,8 @@ class LibraryManager:
|
|||||||
page = Page(size=self.page_size, style=page_style)
|
page = Page(size=self.page_size, style=page_style)
|
||||||
canvas = page.render()
|
canvas = page.render()
|
||||||
draw = ImageDraw.Draw(canvas)
|
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
|
||||||
table_style = TableStyle(
|
table_style = TableStyle(
|
||||||
@@ -289,6 +370,8 @@ class LibraryManager:
|
|||||||
table_width = page.size[0] - page_style.padding[1] - page_style.padding[3]
|
table_width = page.size[0] - page_style.padding[1] - page_style.padding[3]
|
||||||
|
|
||||||
# Render table with canvas support for images
|
# 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(
|
self.table_renderer = TableRenderer(
|
||||||
table,
|
table,
|
||||||
table_origin,
|
table_origin,
|
||||||
@@ -298,18 +381,24 @@ class LibraryManager:
|
|||||||
canvas # Pass canvas to enable image rendering
|
canvas # Pass canvas to enable image rendering
|
||||||
)
|
)
|
||||||
self.table_renderer.render()
|
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
|
# Store rendered page for query support
|
||||||
self.rendered_page = page
|
self.rendered_page = page
|
||||||
|
|
||||||
|
total_elapsed = time.time() - start_time
|
||||||
|
logger.info(f"[LIBRARY] Total render time: {total_elapsed:.2f}s")
|
||||||
|
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
def handle_library_tap(self, x: int, y: int) -> Optional[str]:
|
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
|
The layout has alternating rows: cover rows and detail rows.
|
||||||
book path. This makes the entire row interactive, not just the cover image.
|
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:
|
Args:
|
||||||
x: X coordinate of tap
|
x: X coordinate of tap
|
||||||
@@ -323,6 +412,11 @@ class LibraryManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
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
|
# Build a mapping of row sections in order
|
||||||
all_rows = list(self.library_table.all_rows())
|
all_rows = list(self.library_table.all_rows())
|
||||||
|
|
||||||
@@ -345,11 +439,43 @@ class LibraryManager:
|
|||||||
# Find which body row this is (0-indexed)
|
# Find which body row this is (0-indexed)
|
||||||
body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body")
|
body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body")
|
||||||
|
|
||||||
# Return the corresponding book
|
# Each pair of books uses 2 rows (cover row + detail row)
|
||||||
if body_row_index < len(self.books):
|
# Determine which book pair this row belongs to
|
||||||
book_path = self.books[body_row_index]['path']
|
book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...)
|
||||||
print(f"Book selected (row {body_row_index}): {book_path}")
|
is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details
|
||||||
return book_path
|
|
||||||
|
# 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})")
|
print(f"No book tapped at ({x}, {y})")
|
||||||
return None
|
return None
|
||||||
@@ -374,6 +500,56 @@ class LibraryManager:
|
|||||||
return self.books[index]
|
return self.books[index]
|
||||||
return None
|
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:
|
def get_library_state(self) -> LibraryState:
|
||||||
"""
|
"""
|
||||||
Get current library state for persistence.
|
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
|
||||||
@@ -52,7 +52,7 @@ class DocumentManager:
|
|||||||
|
|
||||||
# Extract metadata
|
# Extract metadata
|
||||||
self.title = book.get_title() or "Unknown Title"
|
self.title = book.get_title() or "Unknown Title"
|
||||||
self.author = book.get_metadata('AUTHOR') or "Unknown Author"
|
self.author = book.get_author() or "Unknown Author"
|
||||||
|
|
||||||
# Create document ID from filename
|
# Create document ID from filename
|
||||||
self.document_id = Path(epub_path).stem
|
self.document_id = Path(epub_path).stem
|
||||||
@@ -70,6 +70,9 @@ class DocumentManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading EPUB: {e}")
|
print(f"Error loading EPUB: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"Full traceback:")
|
||||||
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_html(self, html_string: str, title: str = "HTML Document",
|
def load_html(self, html_string: str, title: str = "HTML Document",
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ from typing import Dict, Any, Optional
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
from pyWebLayout.style.fonts import BundledFont
|
||||||
|
|
||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
"""
|
"""
|
||||||
Manages font size, spacing, and rendering settings.
|
Manages font size, spacing, font family, and rendering settings.
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- Font scale adjustment
|
- Font scale adjustment
|
||||||
|
- Font family selection (serif, sans-serif, monospace)
|
||||||
- Line spacing control
|
- Line spacing control
|
||||||
- Inter-block spacing control
|
- Inter-block spacing control
|
||||||
- Word spacing control
|
- Word spacing control
|
||||||
@@ -27,6 +29,7 @@ class SettingsManager:
|
|||||||
"""Initialize the settings manager."""
|
"""Initialize the settings manager."""
|
||||||
self.font_scale = 1.0
|
self.font_scale = 1.0
|
||||||
self.font_scale_step = 0.1 # 10% change per step
|
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
|
self.manager: Optional[EreaderLayoutManager] = None
|
||||||
|
|
||||||
def set_manager(self, manager: EreaderLayoutManager):
|
def set_manager(self, manager: EreaderLayoutManager):
|
||||||
@@ -38,6 +41,7 @@ class SettingsManager:
|
|||||||
"""
|
"""
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.font_scale = manager.font_scale
|
self.font_scale = manager.font_scale
|
||||||
|
self.font_family = manager.get_font_family()
|
||||||
|
|
||||||
def set_font_size(self, scale: float) -> Optional[Image.Image]:
|
def set_font_size(self, scale: float) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
@@ -89,6 +93,36 @@ class SettingsManager:
|
|||||||
"""
|
"""
|
||||||
return self.font_scale
|
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]:
|
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Set line spacing using pyWebLayout's native support.
|
Set line spacing using pyWebLayout's native support.
|
||||||
@@ -195,6 +229,7 @@ class SettingsManager:
|
|||||||
if not self.manager:
|
if not self.manager:
|
||||||
return {
|
return {
|
||||||
'font_scale': self.font_scale,
|
'font_scale': self.font_scale,
|
||||||
|
'font_family': self.font_family.name if self.font_family else None,
|
||||||
'line_spacing': 5,
|
'line_spacing': 5,
|
||||||
'inter_block_spacing': 15,
|
'inter_block_spacing': 15,
|
||||||
'word_spacing': 0
|
'word_spacing': 0
|
||||||
@@ -202,6 +237,7 @@ class SettingsManager:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'font_scale': self.font_scale,
|
'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,
|
'line_spacing': self.manager.page_style.line_spacing,
|
||||||
'inter_block_spacing': self.manager.page_style.inter_block_spacing,
|
'inter_block_spacing': self.manager.page_style.inter_block_spacing,
|
||||||
'word_spacing': self.manager.page_style.word_spacing
|
'word_spacing': self.manager.page_style.word_spacing
|
||||||
@@ -214,7 +250,7 @@ class SettingsManager:
|
|||||||
This should be called after loading a book to restore user preferences.
|
This should be called after loading a book to restore user preferences.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Dictionary with settings (font_scale, line_spacing, etc.)
|
settings: Dictionary with settings (font_scale, font_family, line_spacing, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if settings applied successfully, False otherwise
|
True if settings applied successfully, False otherwise
|
||||||
@@ -223,6 +259,19 @@ class SettingsManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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
|
# Apply font scale
|
||||||
font_scale = settings.get('font_scale', 1.0)
|
font_scale = settings.get('font_scale', 1.0)
|
||||||
if font_scale != self.font_scale:
|
if font_scale != self.font_scale:
|
||||||
|
|||||||
@@ -1,453 +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,
|
|
||||||
font_scale: float = 1.0,
|
|
||||||
line_spacing: int = 5,
|
|
||||||
inter_block_spacing: int = 15,
|
|
||||||
word_spacing: int = 0
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Open the settings overlay with current settings values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_page: Current reading page to show underneath
|
|
||||||
font_scale: Current font scale
|
|
||||||
line_spacing: Current line spacing
|
|
||||||
inter_block_spacing: Current inter-block spacing
|
|
||||||
word_spacing: Current word spacing
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Composited image with settings 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)
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
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="Settings",
|
|
||||||
author="",
|
|
||||||
document_id="settings_overlay"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise ValueError("Failed to load settings 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.SETTINGS
|
|
||||||
|
|
||||||
# Composite and return
|
|
||||||
return self.composite_overlay(base_page, overlay_panel)
|
|
||||||
|
|
||||||
def refresh_settings_overlay(
|
|
||||||
self,
|
|
||||||
updated_base_page: Image.Image,
|
|
||||||
font_scale: float,
|
|
||||||
line_spacing: int,
|
|
||||||
inter_block_spacing: int,
|
|
||||||
word_spacing: int = 0
|
|
||||||
) -> 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
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Composited image with updated settings overlay
|
|
||||||
"""
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
page_size=(panel_width, panel_height)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Recreate overlay reader with updated HTML
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
success = self._overlay_reader.load_html(
|
|
||||||
html_string=html,
|
|
||||||
title="Settings",
|
|
||||||
author="",
|
|
||||||
document_id="settings_overlay"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise ValueError("Failed to load updated settings overlay HTML")
|
|
||||||
|
|
||||||
# Get the updated rendered panel
|
|
||||||
overlay_panel = self._overlay_reader.get_current_page()
|
|
||||||
|
|
||||||
# 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 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):
|
class OverlayState(Enum):
|
||||||
"""Overlay states within READING mode"""
|
"""Overlay states within READING mode"""
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
TOC = "toc"
|
TOC = "toc" # Deprecated: use NAVIGATION instead
|
||||||
SETTINGS = "settings"
|
SETTINGS = "settings"
|
||||||
BOOKMARKS = "bookmarks"
|
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
|
||||||
|
NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# Library Reading Demo
|
|
||||||
|
|
||||||
This directory contains scripts to demonstrate the complete LIBRARY ↔ READING workflow for the dreader e-reader application.
|
|
||||||
|
|
||||||
## Demo GIF
|
|
||||||
|
|
||||||
**File**: [`doc/images/library_reading_demo.gif`](../doc/images/library_reading_demo.gif) (591 KB, 800x1200 pixels)
|
|
||||||
|
|
||||||
### What the Demo Shows
|
|
||||||
|
|
||||||
The animated GIF demonstrates the complete user workflow:
|
|
||||||
|
|
||||||
1. **Library View** (2s)
|
|
||||||
- Shows a grid of available books
|
|
||||||
- Title: "📚 My Library - Select a book"
|
|
||||||
|
|
||||||
2. **Book Selection** (1.5s)
|
|
||||||
- Visual tap indicator on the first book
|
|
||||||
- Shows where user taps to select
|
|
||||||
|
|
||||||
3. **Reading Pages** (5 frames, ~5s total)
|
|
||||||
- Opens "Alice's Adventures in Wonderland"
|
|
||||||
- Shows 5 consecutive pages
|
|
||||||
- Page turns are animated
|
|
||||||
- Progress shown in header
|
|
||||||
|
|
||||||
4. **Settings Overlay** (2s)
|
|
||||||
- Shows settings panel with font controls
|
|
||||||
- Highlights "Back to Library" button
|
|
||||||
- Visual tap indicator showing where to click
|
|
||||||
|
|
||||||
5. **Return to Library** (2s)
|
|
||||||
- Book closes, position saved automatically
|
|
||||||
- Library view shown again
|
|
||||||
- Annotation: "Back to Library (position saved)"
|
|
||||||
|
|
||||||
6. **Reopen Book** (1.5s)
|
|
||||||
- User taps same book again
|
|
||||||
- Visual indicator shows reselection
|
|
||||||
|
|
||||||
7. **Auto-Resume** (3s)
|
|
||||||
- Book opens at saved position (24.6% progress)
|
|
||||||
- Shows the exact page where user left off
|
|
||||||
- Annotation: "✅ Auto-resumed at 24.6%"
|
|
||||||
|
|
||||||
**Total Duration**: ~17 seconds (looping)
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
### `generate_library_demo_gif.py`
|
|
||||||
|
|
||||||
Generate the demo GIF showing the complete workflow.
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
python generate_library_demo_gif.py path/to/library [output.gif]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Examples**:
|
|
||||||
```bash
|
|
||||||
# Generate to default location (doc/images/library_reading_demo.gif)
|
|
||||||
python generate_library_demo_gif.py tests/data/library-epub/
|
|
||||||
|
|
||||||
# Generate to custom location
|
|
||||||
python generate_library_demo_gif.py tests/data/library-epub/ doc/images/custom_demo.gif
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Automatic book scanning and cover extraction
|
|
||||||
- Visual tap indicators showing user interactions
|
|
||||||
- Annotations explaining each step
|
|
||||||
- Configurable frame durations
|
|
||||||
- Auto-resume demonstration
|
|
||||||
|
|
||||||
### `library_reading_integration.py`
|
|
||||||
|
|
||||||
Comprehensive integration test for the library ↔ reading workflow.
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
python library_reading_integration.py path/to/library
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it Tests**:
|
|
||||||
1. Library scanning and rendering
|
|
||||||
2. Book selection via tap
|
|
||||||
3. Book loading and reading
|
|
||||||
4. Page navigation (swipe gestures)
|
|
||||||
5. Settings overlay
|
|
||||||
6. Settings adjustments (font size)
|
|
||||||
7. Back to library button
|
|
||||||
8. Auto-resume functionality
|
|
||||||
9. Multiple book selection
|
|
||||||
|
|
||||||
**Output**: Generates PNG images for each step (8 images total)
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### ✅ Complete Features
|
|
||||||
|
|
||||||
- **Library Management** ([library.py](../dreader/library.py))
|
|
||||||
- Book scanning and metadata extraction
|
|
||||||
- Cover image caching
|
|
||||||
- Interactive book selection
|
|
||||||
- Clickable book rows
|
|
||||||
|
|
||||||
- **Reading Mode** ([application.py](../dreader/application.py))
|
|
||||||
- EPUB rendering
|
|
||||||
- Page navigation (swipe, tap)
|
|
||||||
- Progress tracking
|
|
||||||
- Position saving/loading
|
|
||||||
|
|
||||||
- **State Persistence** ([state.py](../dreader/state.py))
|
|
||||||
- Auto-save on page turn
|
|
||||||
- Resume at last position
|
|
||||||
- Settings persistence
|
|
||||||
- Per-book bookmarks
|
|
||||||
|
|
||||||
- **Overlays**
|
|
||||||
- TOC (Table of Contents) - ✅ Working
|
|
||||||
- Settings - ✅ Working
|
|
||||||
- Bookmarks - ✅ Working
|
|
||||||
|
|
||||||
### 🚧 Known Issues
|
|
||||||
|
|
||||||
- **HTML Link Interactivity**: The "Back to Library" button in settings overlay doesn't respond to taps
|
|
||||||
- Root cause documented in [HTML_LINKS_INVESTIGATION.md](../HTML_LINKS_INVESTIGATION.md)
|
|
||||||
- Workaround: Will be implemented using programmatic UI generation
|
|
||||||
- Does not affect the demo GIF (which shows the intended workflow)
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python 3.8+
|
|
||||||
- PIL/Pillow (for image generation)
|
|
||||||
- dreader application
|
|
||||||
- pyWebLayout library
|
|
||||||
- Test EPUB files in the library directory
|
|
||||||
|
|
||||||
## File Sizes
|
|
||||||
|
|
||||||
- Demo GIF: ~622 KB (optimized for quality)
|
|
||||||
- Integration test PNGs: ~50-170 KB each
|
|
||||||
- Total demo assets: <2 MB
|
|
||||||
|
|
||||||
## Demo Generation Time
|
|
||||||
|
|
||||||
- Library scanning: <1 second
|
|
||||||
- EPUB loading: <1 second
|
|
||||||
- Page rendering: ~0.5 seconds per page
|
|
||||||
- Total: ~10-15 seconds to generate complete GIF
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
1. **Documentation**: Visual demonstration of application features
|
|
||||||
2. **Testing**: Verify complete workflow end-to-end
|
|
||||||
3. **Presentations**: Show stakeholders the user experience
|
|
||||||
4. **Debugging**: Identify issues in the workflow
|
|
||||||
5. **Training**: Help users understand the application flow
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [REQUIREMENTS.md](../REQUIREMENTS.md) - Full application requirements
|
|
||||||
- [HTML_GENERATION.md](../HTML_GENERATION.md) - HTML rendering documentation
|
|
||||||
- [HTML_LINKS_INVESTIGATION.md](../HTML_LINKS_INVESTIGATION.md) - Link interactivity debugging
|
|
||||||
@@ -1,421 +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, block, and word spacing
|
|
||||||
- 💾 **Persistent Settings** - Save and restore rendering preferences across sessions
|
|
||||||
- 📊 **Progress Tracking** - Get reading progress and position information
|
|
||||||
- 🎨 **Text Highlighting** - Highlight words and passages with colors
|
|
||||||
- 📋 **Overlays** - TOC, Settings, and Bookmarks overlays
|
|
||||||
- 🖱️ **Gesture Support** - Handle tap, swipe, pinch gestures
|
|
||||||
- 💾 **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}%")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persistent Settings
|
|
||||||
|
|
||||||
Settings like font size and spacing are automatically saved and restored across sessions:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from dreader import EbookReader
|
|
||||||
from dreader.state import StateManager
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
print(f"Saved font scale: {state.settings.font_scale}")
|
|
||||||
|
|
||||||
# Create reader with saved settings
|
|
||||||
reader = EbookReader(
|
|
||||||
line_spacing=state.settings.line_spacing,
|
|
||||||
inter_block_spacing=state.settings.inter_block_spacing
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load book and apply all saved settings
|
|
||||||
reader.load_epub("mybook.epub")
|
|
||||||
reader.apply_settings(state.settings.to_dict())
|
|
||||||
|
|
||||||
# User changes settings...
|
|
||||||
reader.increase_font_size()
|
|
||||||
reader.set_line_spacing(10)
|
|
||||||
|
|
||||||
# Save new settings for next session
|
|
||||||
current_settings = reader.get_current_settings()
|
|
||||||
state_manager.update_settings(current_settings)
|
|
||||||
state_manager.save_state()
|
|
||||||
|
|
||||||
# Next time the app starts, these settings will be restored!
|
|
||||||
```
|
|
||||||
|
|
||||||
See [persistent_settings_example.py](persistent_settings_example.py) for a complete demonstration.
|
|
||||||
|
|
||||||
## Demo Scripts
|
|
||||||
|
|
||||||
Run these demos to see features in action:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Comprehensive feature demo
|
|
||||||
python examples/ereader_demo.py path/to/book.epub
|
|
||||||
|
|
||||||
# Persistent settings demo
|
|
||||||
python examples/persistent_settings_example.py
|
|
||||||
|
|
||||||
# TOC overlay demo (generates animated GIF)
|
|
||||||
python examples/demo_toc_overlay.py
|
|
||||||
|
|
||||||
# Settings overlay demo (generates animated GIF)
|
|
||||||
python examples/demo_settings_overlay.py
|
|
||||||
|
|
||||||
# Word highlighting examples
|
|
||||||
python examples/word_selection_highlighting.py
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -156,16 +156,14 @@ def main():
|
|||||||
print("=== Settings Overlay Demo ===")
|
print("=== Settings Overlay Demo ===")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Find a test EPUB
|
# Use Alice in Wonderland test book (has actual content)
|
||||||
epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub'
|
epub_path = Path(__file__).parent.parent / 'tests' / 'data' / 'test.epub'
|
||||||
epubs = list(epub_dir.glob('*.epub'))
|
|
||||||
|
|
||||||
if not epubs:
|
if not epub_path.exists():
|
||||||
print("Error: No test EPUB files found!")
|
print("Error: test.epub not found!")
|
||||||
print(f"Looked in: {epub_dir}")
|
print(f"Looked in: {epub_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
epub_path = epubs[0]
|
|
||||||
print(f"Using book: {epub_path.name}")
|
print(f"Using book: {epub_path.name}")
|
||||||
|
|
||||||
# Create reader
|
# Create reader
|
||||||
@@ -182,6 +180,10 @@ def main():
|
|||||||
print(f"Loaded: {reader.book_title} by {reader.book_author}")
|
print(f"Loaded: {reader.book_title} by {reader.book_author}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
# Skip to a page with actual content (past cover/title pages)
|
||||||
|
for _ in range(3):
|
||||||
|
reader.next_page()
|
||||||
|
|
||||||
# Prepare frames for GIF
|
# Prepare frames for GIF
|
||||||
frames = []
|
frames = []
|
||||||
frame_duration = [] # Duration in milliseconds for each frame
|
frame_duration = [] # Duration in milliseconds for each frame
|
||||||
@@ -215,8 +217,8 @@ def main():
|
|||||||
# Find actual button coordinates by querying the overlay
|
# Find actual button coordinates by querying the overlay
|
||||||
print("Querying overlay for button positions...")
|
print("Querying overlay for button positions...")
|
||||||
link_positions = {}
|
link_positions = {}
|
||||||
if reader.overlay_manager._overlay_reader:
|
if reader._active_overlay and reader._active_overlay._overlay_reader:
|
||||||
page = reader.overlay_manager._overlay_reader.manager.get_current_page()
|
page = reader._active_overlay._overlay_reader.manager.get_current_page()
|
||||||
|
|
||||||
# Scan for all links with very fine granularity to catch all buttons
|
# Scan for all links with very fine granularity to catch all buttons
|
||||||
for y in range(0, 840, 3):
|
for y in range(0, 840, 3):
|
||||||
|
|||||||
@@ -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,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
|
from PIL import Image, ImageDraw
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from dreader.application import EbookReader
|
from dreader import EbookReader, TouchEvent, GestureType
|
||||||
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
|
||||||
from pyWebLayout.core.query import QueryResult
|
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 ""
|
||||||
@@ -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
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
from dreader import LibraryManager
|
from dreader import LibraryManager
|
||||||
|
|
||||||
|
|
||||||
@@ -39,16 +40,21 @@ class TestLibraryInteraction(unittest.TestCase):
|
|||||||
self.assertIn('filename', book)
|
self.assertIn('filename', book)
|
||||||
|
|
||||||
def test_library_table_creation(self):
|
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()
|
books = self.library.scan_library()
|
||||||
table = self.library.create_library_table()
|
table = self.library.create_library_table()
|
||||||
|
|
||||||
# Table should exist
|
# Table should exist
|
||||||
self.assertIsNotNone(table)
|
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())
|
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):
|
def test_library_rendering(self):
|
||||||
"""Test that library can be rendered to image"""
|
"""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")
|
self.assertIsNone(selected_path, "Tap below last book should not select anything")
|
||||||
|
|
||||||
def test_multiple_taps(self):
|
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()
|
books = self.library.scan_library()
|
||||||
|
|
||||||
if len(books) < 3:
|
if len(books) < 3:
|
||||||
@@ -145,16 +151,20 @@ class TestLibraryInteraction(unittest.TestCase):
|
|||||||
self.library.create_library_table()
|
self.library.create_library_table()
|
||||||
self.library.render_library()
|
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)
|
path1 = self.library.handle_library_tap(x=100, y=100)
|
||||||
self.assertEqual(path1, books[0]['path'])
|
self.assertEqual(path1, books[0]['path'])
|
||||||
|
|
||||||
# Tap second book (row 1: y=181-301)
|
# Tap second book (right column, first pair cover row)
|
||||||
path2 = self.library.handle_library_tap(x=400, y=250)
|
path2 = self.library.handle_library_tap(x=500, y=100)
|
||||||
self.assertEqual(path2, books[1]['path'])
|
self.assertEqual(path2, books[1]['path'])
|
||||||
|
|
||||||
# Tap third book (row 2: y=302-422)
|
# Tap third book (left column, second pair cover row)
|
||||||
path3 = self.library.handle_library_tap(x=400, y=360)
|
path3 = self.library.handle_library_tap(x=100, y=360)
|
||||||
self.assertEqual(path3, books[2]['path'])
|
self.assertEqual(path3, books[2]['path'])
|
||||||
|
|
||||||
# All should be different
|
# All should be different
|
||||||
@@ -162,6 +172,79 @@ class TestLibraryInteraction(unittest.TestCase):
|
|||||||
self.assertNotEqual(path2, path3)
|
self.assertNotEqual(path2, path3)
|
||||||
self.assertNotEqual(path1, 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__':
|
if __name__ == '__main__':
|
||||||
unittest.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"])
|
||||||
@@ -24,21 +24,67 @@ class TestSettingsOverlay(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test reader with a book"""
|
"""Set up test reader with a book"""
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
self.reader = EbookReader(page_size=(800, 1200))
|
self.reader = EbookReader(page_size=(800, 1200))
|
||||||
|
|
||||||
# Load a test EPUB
|
# Load a test EPUB - use a larger EPUB for spacing tests
|
||||||
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
|
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
||||||
if not test_epub.exists():
|
epubs = list(epub_dir.glob('*.epub'))
|
||||||
# Try to find any EPUB in test data
|
if not epubs:
|
||||||
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
self.skipTest("No test EPUB files available")
|
||||||
epubs = list(epub_dir.glob('*.epub'))
|
|
||||||
if epubs:
|
|
||||||
test_epub = epubs[0]
|
|
||||||
else:
|
|
||||||
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))
|
success = self.reader.load_epub(str(test_epub))
|
||||||
self.assertTrue(success, "Failed to load 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):
|
def tearDown(self):
|
||||||
"""Clean up"""
|
"""Clean up"""
|
||||||
@@ -135,9 +181,12 @@ class TestSettingsOverlay(unittest.TestCase):
|
|||||||
self.reader.open_settings_overlay()
|
self.reader.open_settings_overlay()
|
||||||
initial_font_scale = self.reader.base_font_scale
|
initial_font_scale = self.reader.base_font_scale
|
||||||
|
|
||||||
# Get overlay reader to query button positions
|
# Get overlay reader to query button positions from the active overlay sub-application
|
||||||
overlay_manager = self.reader.overlay_manager
|
overlay_subapp = self.reader._active_overlay
|
||||||
overlay_reader = overlay_manager._overlay_reader
|
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:
|
if not overlay_reader or not overlay_reader.manager:
|
||||||
self.skipTest("Overlay reader not available for querying")
|
self.skipTest("Overlay reader not available for querying")
|
||||||
@@ -262,15 +311,17 @@ class TestSettingsOverlay(unittest.TestCase):
|
|||||||
# Open overlay
|
# Open overlay
|
||||||
self.reader.open_settings_overlay()
|
self.reader.open_settings_overlay()
|
||||||
|
|
||||||
# Access refresh method through overlay manager
|
# Access refresh method through active overlay sub-application
|
||||||
overlay_manager = self.reader.overlay_manager
|
overlay_subapp = self.reader._active_overlay
|
||||||
|
if not overlay_subapp:
|
||||||
|
self.skipTest("No active overlay sub-application")
|
||||||
|
|
||||||
# Change a setting programmatically
|
# Change a setting programmatically
|
||||||
self.reader.increase_font_size()
|
self.reader.increase_font_size()
|
||||||
new_page = self.reader.get_current_page(include_highlights=False)
|
new_page = self.reader.get_current_page(include_highlights=False)
|
||||||
|
|
||||||
# Refresh overlay
|
# Refresh overlay
|
||||||
refreshed_image = overlay_manager.refresh_settings_overlay(
|
refreshed_image = overlay_subapp.refresh(
|
||||||
updated_base_page=new_page,
|
updated_base_page=new_page,
|
||||||
font_scale=self.reader.base_font_scale,
|
font_scale=self.reader.base_font_scale,
|
||||||
line_spacing=self.reader.page_style.line_spacing,
|
line_spacing=self.reader.page_style.line_spacing,
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ class TestTOCOverlay(unittest.TestCase):
|
|||||||
self.reader.close()
|
self.reader.close()
|
||||||
|
|
||||||
def test_overlay_manager_initialization(self):
|
def test_overlay_manager_initialization(self):
|
||||||
"""Test that overlay manager is properly initialized"""
|
"""Test that overlay sub-applications are properly initialized"""
|
||||||
self.assertIsNotNone(self.reader.overlay_manager)
|
# Check that overlay sub-applications exist
|
||||||
self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200))
|
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.assertFalse(self.reader.is_overlay_open())
|
||||||
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
|
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
|
||||||
|
|
||||||
@@ -94,14 +99,14 @@ class TestTOCOverlay(unittest.TestCase):
|
|||||||
# Handle gesture
|
# Handle gesture
|
||||||
response = self.reader.handle_touch(event)
|
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.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())
|
self.assertTrue(self.reader.is_overlay_open())
|
||||||
|
|
||||||
def test_swipe_up_from_middle_does_not_open_toc(self):
|
def test_swipe_up_from_middle_opens_navigation(self):
|
||||||
"""Test that swipe up from middle of screen does NOT open TOC"""
|
"""Test that swipe up from anywhere opens navigation overlay"""
|
||||||
# Create swipe up event from middle of screen (y=600, which is < 80% of 1200)
|
# Create swipe up event from middle of screen
|
||||||
event = TouchEvent(
|
event = TouchEvent(
|
||||||
gesture=GestureType.SWIPE_UP,
|
gesture=GestureType.SWIPE_UP,
|
||||||
x=400,
|
x=400,
|
||||||
@@ -111,9 +116,10 @@ class TestTOCOverlay(unittest.TestCase):
|
|||||||
# Handle gesture
|
# Handle gesture
|
||||||
response = self.reader.handle_touch(event)
|
response = self.reader.handle_touch(event)
|
||||||
|
|
||||||
# Should not open overlay
|
# Should open navigation overlay from anywhere
|
||||||
self.assertEqual(response.action, ActionType.NONE)
|
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
||||||
self.assertFalse(self.reader.is_overlay_open())
|
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
|
||||||
|
self.assertTrue(self.reader.is_overlay_open())
|
||||||
|
|
||||||
def test_swipe_down_closes_overlay(self):
|
def test_swipe_down_closes_overlay(self):
|
||||||
"""Test that swipe down closes the overlay"""
|
"""Test that swipe down closes the overlay"""
|
||||||
@@ -297,13 +303,153 @@ class TestOverlayRendering(unittest.TestCase):
|
|||||||
self.assertIsNotNone(html)
|
self.assertIsNotNone(html)
|
||||||
self.assertIn("Table of Contents", html)
|
self.assertIn("Table of Contents", html)
|
||||||
|
|
||||||
# Render HTML to image using overlay manager
|
# Open the TOC overlay which internally renders HTML to image
|
||||||
overlay_manager = self.reader.overlay_manager
|
overlay_image = self.reader.open_toc_overlay()
|
||||||
image = overlay_manager.render_html_to_image(html)
|
|
||||||
|
|
||||||
# Should produce valid image
|
# Should produce valid image
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(overlay_image)
|
||||||
self.assertEqual(image.size, (800, 1200))
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||