Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18be4306bf | ||
|
|
7518bcf835 | ||
|
|
5d3e7fae7b | ||
|
|
b5ae08c2b5 | ||
|
|
cda1ec3363 | ||
|
|
fe140ba91f | ||
|
|
4811367905 | ||
|
|
11dc30ba8d | ||
|
|
531d02735c | ||
|
|
1993b0bf48 | ||
|
|
284a6e3393 | ||
|
|
60426432a0 | ||
|
|
06c4a8504b | ||
|
|
131d39080e | ||
|
|
c4b4b0a298 | ||
|
|
68b3570040 | ||
|
|
9862c3124a | ||
|
|
2e926ab87a |
@@ -1,2 +0,0 @@
|
|||||||
# Mark EPUB files as binary to prevent any text transformations
|
|
||||||
*.epub binary
|
|
||||||
@@ -12,10 +12,6 @@ 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
|
||||||
@@ -23,21 +19,19 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: '3.x'
|
||||||
|
|
||||||
- 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 --no-cache-dir git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
|
pip install --upgrade --force-reinstall --no-deps 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: |
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "external/dreader-hal"]
|
|
||||||
path = external/dreader-hal
|
|
||||||
url = https://gitea.tourolle.paris/dtourolle/dreader-hal
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
# Accelerometer-Based Page Flipping
|
|
||||||
|
|
||||||
This document describes the accelerometer-based page flipping feature that allows users to navigate pages by tilting the device.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The accelerometer page flipping feature uses the BMA400 3-axis accelerometer to detect device tilt and automatically turn pages. This provides a hands-free way to read, which is useful when:
|
|
||||||
|
|
||||||
- Eating or drinking while reading
|
|
||||||
- Holding the device with one hand
|
|
||||||
- Device is mounted (e.g., on a stand)
|
|
||||||
- Accessibility needs
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
1. **Gesture Types** ([dreader/gesture.py](dreader/gesture.py:29-30))
|
|
||||||
- `TILT_FORWARD` - Tilt device forward to go to next page
|
|
||||||
- `TILT_BACKWARD` - Tilt device backward to go to previous page
|
|
||||||
|
|
||||||
2. **HAL Integration** ([dreader/hal_hardware.py](dreader/hal_hardware.py:414-563))
|
|
||||||
- `load_accelerometer_calibration()` - Loads calibration from JSON file
|
|
||||||
- `get_tilt_gesture()` - Polls accelerometer and detects tilt gestures
|
|
||||||
- Gravity direction calculation based on calibrated "up" vector
|
|
||||||
- Debouncing to prevent multiple page flips from single tilt
|
|
||||||
|
|
||||||
3. **Gesture Handlers** ([dreader/handlers/gestures.py](dreader/handlers/gestures.py:84-87))
|
|
||||||
- `TILT_FORWARD` → calls `_handle_page_forward()`
|
|
||||||
- `TILT_BACKWARD` → calls `_handle_page_back()`
|
|
||||||
- Uses same page navigation logic as swipe gestures
|
|
||||||
|
|
||||||
4. **Calibration Tool** ([examples/calibrate_accelerometer.py](examples/calibrate_accelerometer.py))
|
|
||||||
- Interactive calibration using display
|
|
||||||
- Shows live arrow pointing in gravity direction
|
|
||||||
- User rotates device until arrow points "up"
|
|
||||||
- Saves calibration to JSON file
|
|
||||||
|
|
||||||
5. **Demo Application** ([examples/demo_accelerometer_page_flip.py](examples/demo_accelerometer_page_flip.py))
|
|
||||||
- Complete integration example
|
|
||||||
- Combines touch and accelerometer gestures
|
|
||||||
- Shows how to poll both input sources
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Calibration
|
|
||||||
|
|
||||||
The calibration process establishes which direction is "up" for the device:
|
|
||||||
|
|
||||||
1. Run `python examples/calibrate_accelerometer.py`
|
|
||||||
2. Device displays an arrow showing gravity direction
|
|
||||||
3. Rotate device until arrow points up
|
|
||||||
4. Tap screen to save calibration
|
|
||||||
5. Calibration stored in `accelerometer_config.json`
|
|
||||||
|
|
||||||
**Calibration Data:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"up_vector": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 9.8,
|
|
||||||
"z": 0.0
|
|
||||||
},
|
|
||||||
"tilt_threshold": 0.3,
|
|
||||||
"debounce_time": 0.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tilt Detection Algorithm
|
|
||||||
|
|
||||||
The algorithm detects when the device is tilted beyond a threshold angle from the calibrated "up" position:
|
|
||||||
|
|
||||||
1. **Read Accelerometer**: Get (x, y, z) acceleration in m/s²
|
|
||||||
2. **Normalize Vectors**: Normalize both current gravity and calibrated up vector
|
|
||||||
3. **Calculate Tilt Angle**:
|
|
||||||
- Project gravity onto plane perpendicular to up vector
|
|
||||||
- Calculate angle using `atan2(perpendicular_magnitude, vertical_component)`
|
|
||||||
4. **Compare to Threshold**: Default 0.3 radians (~17 degrees)
|
|
||||||
5. **Determine Direction**:
|
|
||||||
- Positive perpendicular y-component → Forward tilt → Next page
|
|
||||||
- Negative perpendicular y-component → Backward tilt → Previous page
|
|
||||||
6. **Debounce**: Prevent repeated triggers within debounce time (default 0.5s)
|
|
||||||
|
|
||||||
**Math Details:**
|
|
||||||
|
|
||||||
Given:
|
|
||||||
- Up vector (calibrated): `U = (ux, uy, uz)`
|
|
||||||
- Current gravity: `G = (gx, gy, gz)`
|
|
||||||
|
|
||||||
Calculate:
|
|
||||||
```python
|
|
||||||
# Dot product: 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 (simplified)
|
|
||||||
if perp_y > 0:
|
|
||||||
gesture = TILT_FORWARD
|
|
||||||
else:
|
|
||||||
gesture = TILT_BACKWARD
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Loop Integration
|
|
||||||
|
|
||||||
The main application event loop polls both touch and accelerometer:
|
|
||||||
|
|
||||||
```python
|
|
||||||
while running:
|
|
||||||
# Check touch events
|
|
||||||
touch_event = await hal.get_touch_event()
|
|
||||||
if touch_event:
|
|
||||||
handle_gesture(touch_event)
|
|
||||||
|
|
||||||
# Check accelerometer tilt (if calibrated)
|
|
||||||
if calibrated:
|
|
||||||
tilt_event = await hal.get_tilt_gesture()
|
|
||||||
if tilt_event:
|
|
||||||
handle_gesture(tilt_event)
|
|
||||||
|
|
||||||
await asyncio.sleep(0.05) # ~20Hz polling
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### 1. Calibration (One-time)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/calibrate_accelerometer.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `accelerometer_config.json` in the current directory.
|
|
||||||
|
|
||||||
### 2. Load Calibration in Your Application
|
|
||||||
|
|
||||||
```python
|
|
||||||
from dreader.hal_hardware import HardwareDisplayHAL
|
|
||||||
|
|
||||||
# Create HAL with accelerometer enabled
|
|
||||||
hal = HardwareDisplayHAL(
|
|
||||||
width=1872,
|
|
||||||
height=1404,
|
|
||||||
enable_orientation=True # Important!
|
|
||||||
)
|
|
||||||
|
|
||||||
await hal.initialize()
|
|
||||||
|
|
||||||
# Load calibration
|
|
||||||
if hal.load_accelerometer_calibration("accelerometer_config.json"):
|
|
||||||
print("Accelerometer calibrated!")
|
|
||||||
else:
|
|
||||||
print("No calibration found - tilt gestures disabled")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Poll for Gestures
|
|
||||||
|
|
||||||
**Option A: Unified Event API (Recommended)**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Main event loop - simplest approach
|
|
||||||
while True:
|
|
||||||
# Get event from any source (touch or accelerometer)
|
|
||||||
event = await hal.get_event()
|
|
||||||
|
|
||||||
if event:
|
|
||||||
response = gesture_router.handle_touch(event)
|
|
||||||
# ... process response
|
|
||||||
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Separate Polling (More Control)**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Main event loop - explicit control
|
|
||||||
while True:
|
|
||||||
# Get touch events
|
|
||||||
touch_event = await hal.get_touch_event()
|
|
||||||
|
|
||||||
# Get tilt events (returns None if not calibrated)
|
|
||||||
tilt_event = await hal.get_tilt_gesture()
|
|
||||||
|
|
||||||
# Handle events
|
|
||||||
if touch_event:
|
|
||||||
response = gesture_router.handle_touch(touch_event)
|
|
||||||
# ... process response
|
|
||||||
|
|
||||||
if tilt_event:
|
|
||||||
response = gesture_router.handle_touch(tilt_event)
|
|
||||||
# ... process response
|
|
||||||
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Run Demo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Simple demo using unified 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Tilt Threshold
|
|
||||||
|
|
||||||
Adjust sensitivity by changing `tilt_threshold` in the config file:
|
|
||||||
|
|
||||||
- **0.1 rad (~6°)**: Very sensitive, small tilts trigger pages
|
|
||||||
- **0.3 rad (~17°)**: Default, moderate sensitivity
|
|
||||||
- **0.5 rad (~29°)**: Less sensitive, requires larger tilt
|
|
||||||
|
|
||||||
### Debounce Time
|
|
||||||
|
|
||||||
Adjust `debounce_time` to control how quickly you can trigger repeated page flips:
|
|
||||||
|
|
||||||
- **0.2s**: Fast, can quickly flip multiple pages
|
|
||||||
- **0.5s**: Default, prevents accidental double-flips
|
|
||||||
- **1.0s**: Slow, requires deliberate pauses between flips
|
|
||||||
|
|
||||||
### Example Custom Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"up_vector": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 9.8,
|
|
||||||
"z": 0.0
|
|
||||||
},
|
|
||||||
"tilt_threshold": 0.2,
|
|
||||||
"debounce_time": 0.3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_accelerometer_gestures.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests include:**
|
|
||||||
- Calibration loading
|
|
||||||
- Tilt angle calculation (forward, backward, upright)
|
|
||||||
- Threshold detection
|
|
||||||
- Gesture type definitions
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Accelerometer calibration file not found"
|
|
||||||
|
|
||||||
Run the calibration script first:
|
|
||||||
```bash
|
|
||||||
python examples/calibrate_accelerometer.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tilt gestures not working
|
|
||||||
|
|
||||||
1. Check accelerometer is enabled in HAL:
|
|
||||||
```python
|
|
||||||
hal = HardwareDisplayHAL(enable_orientation=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Verify calibration loaded:
|
|
||||||
```python
|
|
||||||
result = hal.load_accelerometer_calibration()
|
|
||||||
print(f"Calibrated: {result}")
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Check you're polling tilt events:
|
|
||||||
```python
|
|
||||||
tilt_event = await hal.get_tilt_gesture()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tilt too sensitive / not sensitive enough
|
|
||||||
|
|
||||||
Edit `accelerometer_config.json` and adjust `tilt_threshold`:
|
|
||||||
- Lower value = more sensitive
|
|
||||||
- Higher value = less sensitive
|
|
||||||
|
|
||||||
### Pages flip too fast / too slow
|
|
||||||
|
|
||||||
Edit `accelerometer_config.json` and adjust `debounce_time`:
|
|
||||||
- Lower value = faster repeat flips
|
|
||||||
- Higher value = slower repeat flips
|
|
||||||
|
|
||||||
### Wrong direction (forward goes backward)
|
|
||||||
|
|
||||||
The tilt direction detection is device-specific. You may need to adjust the direction logic in [dreader/hal_hardware.py](dreader/hal_hardware.py:547-550):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Current logic (line 547)
|
|
||||||
if perp_y > 0:
|
|
||||||
gesture = AppGestureType.TILT_FORWARD
|
|
||||||
else:
|
|
||||||
gesture = AppGestureType.TILT_BACKWARD
|
|
||||||
|
|
||||||
# Try inverting:
|
|
||||||
if perp_y < 0: # Changed > to <
|
|
||||||
gesture = AppGestureType.TILT_FORWARD
|
|
||||||
else:
|
|
||||||
gesture = AppGestureType.TILT_BACKWARD
|
|
||||||
```
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
1. **Orientation Lock**: Tilt detection assumes fixed device orientation. Auto-rotation may interfere.
|
|
||||||
|
|
||||||
2. **Walking/Movement**: May trigger false positives when walking. Use higher threshold or disable while moving.
|
|
||||||
|
|
||||||
3. **Calibration Drift**: Accelerometer may drift over time. Re-calibrate periodically.
|
|
||||||
|
|
||||||
4. **Direction Heuristic**: Current direction detection is simplified. Complex orientations may not work correctly.
|
|
||||||
|
|
||||||
5. **Single Axis**: Only detects tilt in one plane. Doesn't distinguish left/right tilts.
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
- [ ] Shake gesture to open TOC/settings
|
|
||||||
- [ ] Multi-axis tilt for 4-direction navigation
|
|
||||||
- [ ] Auto-calibration on startup
|
|
||||||
- [ ] Gyroscope integration for rotation gestures
|
|
||||||
- [ ] Adaptive threshold based on reading posture
|
|
||||||
- [ ] Tilt gesture visualization for debugging
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### HardwareDisplayHAL
|
|
||||||
|
|
||||||
#### `load_accelerometer_calibration(config_path: str = "accelerometer_config.json") -> bool`
|
|
||||||
|
|
||||||
Load accelerometer calibration from JSON file.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `config_path`: Path to calibration JSON file
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
- `True` if calibration loaded successfully, `False` otherwise
|
|
||||||
|
|
||||||
#### `async get_event() -> Optional[TouchEvent]`
|
|
||||||
|
|
||||||
**[Recommended]** Get the next event from any input source (touch or accelerometer).
|
|
||||||
|
|
||||||
This is a convenience method that polls both touch and accelerometer in a single call.
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
- `TouchEvent` from either touch sensor or accelerometer
|
|
||||||
- `None` if no event available
|
|
||||||
- Touch events are prioritized over tilt events
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```python
|
|
||||||
while running:
|
|
||||||
event = await hal.get_event()
|
|
||||||
if event:
|
|
||||||
handle_gesture(event)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `async get_tilt_gesture() -> Optional[TouchEvent]`
|
|
||||||
|
|
||||||
Poll accelerometer and check for tilt gestures.
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
- `TouchEvent` with `TILT_FORWARD` or `TILT_BACKWARD` gesture if tilt detected
|
|
||||||
- `None` if no tilt, not calibrated, or within debounce period
|
|
||||||
|
|
||||||
**Note:** Must call `load_accelerometer_calibration()` first. Consider using `get_event()` instead for simpler code.
|
|
||||||
|
|
||||||
### GestureType
|
|
||||||
|
|
||||||
#### `TILT_FORWARD = "tilt_forward"`
|
|
||||||
Gesture type for forward tilt (next page)
|
|
||||||
|
|
||||||
#### `TILT_BACKWARD = "tilt_backward"`
|
|
||||||
Gesture type for backward tilt (previous page)
|
|
||||||
|
|
||||||
### Calibration File Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"up_vector": {
|
|
||||||
"x": float, // X-component of gravity when upright (m/s²)
|
|
||||||
"y": float, // Y-component of gravity when upright (m/s²)
|
|
||||||
"z": float // Z-component of gravity when upright (m/s²)
|
|
||||||
},
|
|
||||||
"tilt_threshold": float, // Tilt angle threshold in radians
|
|
||||||
"debounce_time": float // Minimum time between gestures in seconds
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
See the `examples/` directory for complete working examples:
|
|
||||||
|
|
||||||
- **[calibrate_accelerometer.py](examples/calibrate_accelerometer.py)** - Interactive calibration tool
|
|
||||||
- **[demo_accelerometer_simple.py](examples/demo_accelerometer_simple.py)** - Simple demo using unified `get_event()` API
|
|
||||||
- **[demo_accelerometer_page_flip.py](examples/demo_accelerometer_page_flip.py)** - Full-featured demo with separate event polling
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Same as the main DReader project.
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
# GPIO Button Configuration Guide
|
|
||||||
|
|
||||||
This guide explains how to configure physical buttons for the DReader e-reader.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Physical buttons provide tactile feedback for page turns and navigation without requiring touch input. Buttons are connected between GPIO pins and GND, using internal pull-up resistors.
|
|
||||||
|
|
||||||
## Hardware Setup
|
|
||||||
|
|
||||||
### Basic Button Wiring
|
|
||||||
|
|
||||||
```
|
|
||||||
+3.3V
|
|
||||||
|
|
|
||||||
R (internal pull-up)
|
|
||||||
|
|
|
||||||
GPIO --|------ Button ------ GND
|
|
||||||
|
|
|
||||||
(to BCM2835)
|
|
||||||
```
|
|
||||||
|
|
||||||
When the button is pressed, it connects the GPIO pin to GND (0V), pulling the pin LOW.
|
|
||||||
|
|
||||||
### Recommended Button Layout
|
|
||||||
|
|
||||||
For this e-reader device with 3 buttons:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [Power Off] │ ← Side button (GPIO 21)
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
│ E-INK │
|
|
||||||
│ DISPLAY │
|
|
||||||
│ 1872x1404 │
|
|
||||||
│ │
|
|
||||||
│ [Prev] [Next] │ ← Bottom edge
|
|
||||||
│ │
|
|
||||||
└─────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Button Mapping:**
|
|
||||||
- **Previous Page** (GPIO 22) - Bottom left - Previous page
|
|
||||||
- **Next Page** (GPIO 27) - Bottom right - Next page
|
|
||||||
- **Power Off** (GPIO 21) - Side button - Shutdown device (long press)
|
|
||||||
|
|
||||||
## Software Configuration
|
|
||||||
|
|
||||||
### Using Interactive Setup (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo python3 setup_rpi.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The setup script will:
|
|
||||||
1. Ask if you want GPIO buttons enabled
|
|
||||||
2. Let you configure each button individually
|
|
||||||
3. Allow custom GPIO pin assignments
|
|
||||||
4. Generate hardware_config.json automatically
|
|
||||||
|
|
||||||
### Manual Configuration
|
|
||||||
|
|
||||||
Edit `hardware_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
|
|
||||||
- **enabled** (bool): Enable/disable all GPIO buttons
|
|
||||||
- **pull_up** (bool): Use internal pull-up resistors (always true for button-to-GND wiring)
|
|
||||||
- **bounce_time_ms** (int): Debounce time in milliseconds (default 200ms)
|
|
||||||
- **buttons** (array): List of button configurations
|
|
||||||
|
|
||||||
### Button Configuration
|
|
||||||
|
|
||||||
Each button has:
|
|
||||||
- **name** (string): Unique identifier for the button
|
|
||||||
- **gpio** (int): BCM GPIO pin number (2-27)
|
|
||||||
- **gesture** (string): Gesture type to generate when pressed
|
|
||||||
- **description** (string): Human-readable description
|
|
||||||
|
|
||||||
### Available Gestures
|
|
||||||
|
|
||||||
Buttons can trigger any gesture type:
|
|
||||||
|
|
||||||
| Gesture | Description | Typical Use |
|
|
||||||
|---------|-------------|-------------|
|
|
||||||
| `swipe_left` | Swipe left | Next page |
|
|
||||||
| `swipe_right` | Swipe right | Previous page |
|
|
||||||
| `swipe_up` | Swipe up from bottom | Open navigation/TOC |
|
|
||||||
| `swipe_down` | Swipe down from top | Open settings |
|
|
||||||
| `tap` | Single tap | Select item |
|
|
||||||
| `long_press` | Hold | Context menu |
|
|
||||||
| `pinch_in` | Pinch zoom out | Decrease font size |
|
|
||||||
| `pinch_out` | Pinch zoom in | Increase font size |
|
|
||||||
|
|
||||||
## GPIO Pin Selection
|
|
||||||
|
|
||||||
### Safe GPIO Pins (BCM numbering)
|
|
||||||
|
|
||||||
**Recommended for buttons:**
|
|
||||||
- GPIO 5, 6, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
|
|
||||||
|
|
||||||
**Avoid these pins:**
|
|
||||||
- GPIO 2, 3 - I2C (SDA, SCL) - Used for touch, sensors
|
|
||||||
- GPIO 7-11 - SPI - Used for e-ink display
|
|
||||||
- GPIO 14, 15 - UART - Used for serial console
|
|
||||||
- GPIO 0, 1 - Reserved for ID EEPROM
|
|
||||||
|
|
||||||
### Pin Layout (BCM Mode)
|
|
||||||
|
|
||||||
```
|
|
||||||
3V3 (1) (2) 5V
|
|
||||||
GPIO2 (3) (4) 5V GPIO 2,3 = I2C
|
|
||||||
GPIO3 (5) (6) GND
|
|
||||||
GPIO4 (7) (8) GPIO14 GPIO 7-11 = SPI
|
|
||||||
GND (9) (10) GPIO15 GPIO 14,15 = UART
|
|
||||||
GPIO17 (11) (12) GPIO18
|
|
||||||
GPIO27 (13) (14) GND
|
|
||||||
GPIO22 (15) (16) GPIO23 ← Good for buttons
|
|
||||||
3V3 (17) (18) GPIO24 ← Good for buttons
|
|
||||||
GPIO10 (19) (20) GND
|
|
||||||
GPIO9 (21) (22) GPIO25 ← Good for buttons
|
|
||||||
GPIO11 (23) (24) GPIO8
|
|
||||||
GND (25) (26) GPIO7
|
|
||||||
GPIO0 (27) (28) GPIO1
|
|
||||||
GPIO5 (29) (30) GND ← Good for buttons
|
|
||||||
GPIO6 (31) (32) GPIO12 ← Good for buttons
|
|
||||||
GPIO13 (33) (34) GND ← Good for buttons
|
|
||||||
GPIO19 (35) (36) GPIO16 ← Good for buttons
|
|
||||||
GPIO26 (37) (38) GPIO20 ← Good for buttons
|
|
||||||
GND (39) (40) GPIO21 ← Good for buttons
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Buttons
|
|
||||||
|
|
||||||
### Test Button Connections
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install GPIO utilities
|
|
||||||
sudo apt install gpiod
|
|
||||||
|
|
||||||
# Monitor GPIO 23 (next page button)
|
|
||||||
gpioget gpiochip0 23
|
|
||||||
|
|
||||||
# Press button - should show 0 (LOW)
|
|
||||||
# Release button - should show 1 (HIGH with pull-up)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test in Application
|
|
||||||
|
|
||||||
Run with verbose logging to see button events:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/run_on_hardware_config.py --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
Press each button and verify you see log messages like:
|
|
||||||
```
|
|
||||||
Button pressed: next_page (GPIO 23)
|
|
||||||
Button event queued: next_page -> swipe_left
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Buttons Not Working
|
|
||||||
|
|
||||||
**Problem:** Buttons don't generate events
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check wiring - button should connect GPIO to GND
|
|
||||||
2. Verify GPIO pin number in config (BCM mode, not physical pin)
|
|
||||||
3. Check permissions: `sudo usermod -a -G gpio $USER` then log out/in
|
|
||||||
4. Test GPIO with `gpioget` (see above)
|
|
||||||
5. Check logs: `python examples/run_on_hardware_config.py --verbose`
|
|
||||||
|
|
||||||
### False Triggers
|
|
||||||
|
|
||||||
**Problem:** Button triggers multiple times from single press
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Increase `bounce_time_ms` in config (try 300-500ms)
|
|
||||||
2. Add hardware debounce capacitor (0.1µF between GPIO and GND)
|
|
||||||
3. Check for loose connections
|
|
||||||
|
|
||||||
### Wrong Action
|
|
||||||
|
|
||||||
**Problem:** Button does wrong action
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check `gesture` field in button config
|
|
||||||
2. Verify button name matches intended function
|
|
||||||
3. Check logs to see what gesture is generated
|
|
||||||
|
|
||||||
## Advanced: Custom Button Functions
|
|
||||||
|
|
||||||
You can map buttons to any gesture, creating custom layouts:
|
|
||||||
|
|
||||||
### Example: Reading Mode Buttons
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"gpio_buttons": {
|
|
||||||
"enabled": true,
|
|
||||||
"buttons": [
|
|
||||||
{"name": "next", "gpio": 23, "gesture": "swipe_left"},
|
|
||||||
{"name": "prev", "gpio": 24, "gesture": "swipe_right"},
|
|
||||||
{"name": "zoom_in", "gpio": 25, "gesture": "pinch_out"},
|
|
||||||
{"name": "zoom_out", "gpio": 22, "gesture": "pinch_in"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: Simple 2-Button Layout
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"gpio_buttons": {
|
|
||||||
"enabled": true,
|
|
||||||
"buttons": [
|
|
||||||
{"name": "next", "gpio": 23, "gesture": "swipe_left"},
|
|
||||||
{"name": "prev", "gpio": 24, "gesture": "swipe_right"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hardware Tips
|
|
||||||
|
|
||||||
### Button Quality
|
|
||||||
|
|
||||||
- Use momentary pushbuttons (normally open)
|
|
||||||
- Tactile switches provide good feedback
|
|
||||||
- Rated for at least 10,000 cycles
|
|
||||||
- Consider waterproof buttons for outdoor use
|
|
||||||
|
|
||||||
### Mounting
|
|
||||||
|
|
||||||
- Mount buttons accessible from device edge
|
|
||||||
- Label buttons for user convenience
|
|
||||||
- Consider button guards to prevent accidental presses
|
|
||||||
- Use hot glue or button caps for secure mounting
|
|
||||||
|
|
||||||
### Wiring
|
|
||||||
|
|
||||||
- Keep wires short to reduce noise
|
|
||||||
- Use stranded wire for flexibility
|
|
||||||
- Consider using a ribbon cable for clean routing
|
|
||||||
- Add strain relief at connection points
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete hardware integration guide
|
|
||||||
- [hardware_config.json](hardware_config.json) - Example configuration
|
|
||||||
- [dreader/gpio_buttons.py](dreader/gpio_buttons.py) - Button handler source code
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# DReader Hardware Pinout Reference
|
|
||||||
|
|
||||||
Quick reference for the DReader e-ink device hardware configuration.
|
|
||||||
|
|
||||||
## Display Specifications
|
|
||||||
|
|
||||||
- **Resolution:** 1872 × 1404 pixels
|
|
||||||
- **Controller:** IT8951 (SPI)
|
|
||||||
- **Touch Panel:** FT5316 (I2C)
|
|
||||||
|
|
||||||
## GPIO Pin Assignments
|
|
||||||
|
|
||||||
### Buttons (BCM Numbering)
|
|
||||||
|
|
||||||
| GPIO | Function | Action | Notes |
|
|
||||||
|------|----------|--------|-------|
|
|
||||||
| 21 | Power Off | Long Press (500ms+) | Shutdown button |
|
|
||||||
| 22 | Previous Page | Swipe Right | Left button |
|
|
||||||
| 27 | Next Page | Swipe Left | Right button |
|
|
||||||
|
|
||||||
**Wiring:** All buttons connect between GPIO and GND (pull-up resistors enabled in software)
|
|
||||||
|
|
||||||
### SPI (IT8951 E-ink Display)
|
|
||||||
|
|
||||||
| GPIO | Function | Pin |
|
|
||||||
|------|----------|-----|
|
|
||||||
| 8 | SPI0 CE0 | 24 |
|
|
||||||
| 9 | SPI0 MISO | 21 |
|
|
||||||
| 10 | SPI0 MOSI | 19 |
|
|
||||||
| 11 | SPI0 SCLK | 23 |
|
|
||||||
| 17 | RST | 11 |
|
|
||||||
| 24 | HRDY | 18 |
|
|
||||||
|
|
||||||
### I2C (Touch, Sensors, RTC, Power Monitor)
|
|
||||||
|
|
||||||
| GPIO | Function | Pin | Devices |
|
|
||||||
|------|----------|-----|---------|
|
|
||||||
| 2 | I2C1 SDA | 3 | FT5316 (0x38), BMA400 (0x14), PCF8523 (0x68), INA219 (0x40) |
|
|
||||||
| 3 | I2C1 SCL | 5 | All I2C devices |
|
|
||||||
|
|
||||||
**Note:** I2C bus is shared by all I2C devices. Each device has a unique address.
|
|
||||||
|
|
||||||
## I2C Device Addresses
|
|
||||||
|
|
||||||
| Address | Device | Description |
|
|
||||||
|---------|--------|-------------|
|
|
||||||
| 0x38 | FT5316 | Capacitive touch panel |
|
|
||||||
| 0x14 | BMA400 | 3-axis accelerometer (optional) |
|
|
||||||
| 0x68 | PCF8523 | Real-time clock (optional) |
|
|
||||||
| 0x40 | INA219 | Power monitor (optional) |
|
|
||||||
|
|
||||||
## 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
|
|
||||||
GPIO22 (15) (16) GPIO23
|
|
||||||
3V3 (17) (18) GPIO24 ← Display HRDY
|
|
||||||
GPIO10 (19) (20) GND ← SPI0 MOSI
|
|
||||||
GPIO9 (21) (22) GPIO25
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
Button Connections:
|
|
||||||
GPIO21 ──┤ ├── GND (Power off)
|
|
||||||
GPIO22 ──┤ ├── GND (Previous page)
|
|
||||||
GPIO27 ──┤ ├── GND (Next page)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Configuration Files
|
|
||||||
|
|
||||||
### hardware_config.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display": {
|
|
||||||
"width": 1872,
|
|
||||||
"height": 1404,
|
|
||||||
"vcom": -2.0,
|
|
||||||
"spi_hz": 24000000
|
|
||||||
},
|
|
||||||
"gpio_buttons": {
|
|
||||||
"enabled": true,
|
|
||||||
"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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Connections
|
|
||||||
|
|
||||||
### Check I2C Devices
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scan I2C bus 1 (GPIO 2/3)
|
|
||||||
i2cdetect -y 1
|
|
||||||
|
|
||||||
# Expected output:
|
|
||||||
# 0 1 2 3 4 5 6 7 8 9 a b c d e f
|
|
||||||
# 00: -- -- -- -- -- -- -- -- -- -- -- -- --
|
|
||||||
# 10: -- -- -- -- 14 -- -- -- -- -- -- -- -- -- -- --
|
|
||||||
# 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
|
|
||||||
# 30: -- -- -- -- -- -- -- -- 38 -- -- -- -- -- -- --
|
|
||||||
# 40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
|
|
||||||
# 50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
|
|
||||||
# 60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
|
|
||||||
# 70: -- -- -- -- -- -- -- --
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check SPI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls /dev/spi*
|
|
||||||
# Should show: /dev/spidev0.0 /dev/spidev0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test GPIO Buttons
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install GPIO tools
|
|
||||||
sudo apt install gpiod
|
|
||||||
|
|
||||||
# Test previous page button (GPIO 22)
|
|
||||||
gpioget gpiochip0 22
|
|
||||||
# Press button: shows 0 (LOW)
|
|
||||||
# Release button: shows 1 (HIGH, pulled up)
|
|
||||||
|
|
||||||
# Test next page button (GPIO 27)
|
|
||||||
gpioget gpiochip0 27
|
|
||||||
|
|
||||||
# Test power button (GPIO 21)
|
|
||||||
gpioget gpiochip0 21
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```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. Run interactive setup
|
|
||||||
sudo python3 setup_rpi.py
|
|
||||||
|
|
||||||
# 3. Run DReader
|
|
||||||
python examples/run_on_hardware_config.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### No I2C Devices Detected
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable I2C
|
|
||||||
sudo raspi-config
|
|
||||||
# Navigate to: Interface Options -> I2C -> Enable
|
|
||||||
|
|
||||||
# Check I2C is loaded
|
|
||||||
lsmod | grep i2c
|
|
||||||
# Should show: i2c_dev, i2c_bcm2835
|
|
||||||
|
|
||||||
# Add user to i2c group
|
|
||||||
sudo usermod -a -G i2c $USER
|
|
||||||
# Log out and back in
|
|
||||||
```
|
|
||||||
|
|
||||||
### SPI Not Working
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable SPI
|
|
||||||
sudo raspi-config
|
|
||||||
# Navigate to: Interface Options -> SPI -> Enable
|
|
||||||
|
|
||||||
# Check SPI devices
|
|
||||||
ls -l /dev/spi*
|
|
||||||
|
|
||||||
# Add user to spi group
|
|
||||||
sudo usermod -a -G spi $USER
|
|
||||||
```
|
|
||||||
|
|
||||||
### Buttons Not Responding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add user to gpio group
|
|
||||||
sudo usermod -a -G gpio $USER
|
|
||||||
|
|
||||||
# Test button with direct GPIO access
|
|
||||||
sudo gpioget gpiochip0 22 # Prev button
|
|
||||||
sudo gpioget gpiochip0 27 # Next button
|
|
||||||
sudo gpioget gpiochip0 21 # Power button
|
|
||||||
|
|
||||||
# Check for conflicts
|
|
||||||
# Make sure no other programs are using these GPIOs
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete setup guide
|
|
||||||
- [GPIO_BUTTONS.md](GPIO_BUTTONS.md) - Button configuration reference
|
|
||||||
- [hardware_config.json](hardware_config.json) - Hardware configuration file
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
# Hardware Integration Guide
|
|
||||||
|
|
||||||
This guide explains how to run DReader on real e-ink hardware using the dreader-hal library.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
**On Raspberry Pi:**
|
|
||||||
```bash
|
|
||||||
# 1. Clone and setup
|
|
||||||
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
|
||||||
cd dreader-application
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# 2. Install everything
|
|
||||||
pip install -e .
|
|
||||||
./install_hardware_drivers.sh
|
|
||||||
|
|
||||||
# 3. Run interactive setup (detects hardware and configures)
|
|
||||||
sudo python3 setup_rpi.py
|
|
||||||
|
|
||||||
# 4. Run DReader (uses hardware_config.json)
|
|
||||||
python examples/run_on_hardware_config.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The DReader application uses a plugin-based Hardware Abstraction Layer (HAL) architecture. You can choose different HAL implementations:
|
|
||||||
|
|
||||||
- **PygameDisplayHAL** ([dreader/hal_pygame.py](dreader/hal_pygame.py)) - Desktop testing with pygame window
|
|
||||||
- **HardwareDisplayHAL** ([dreader/hal_hardware.py](dreader/hal_hardware.py)) - Real e-ink hardware via dreader-hal
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
|
|
||||||
- **hardware_config.json** - Hardware configuration (display, buttons, sensors)
|
|
||||||
- **accelerometer_config.json** - Accelerometer calibration for tilt gestures
|
|
||||||
|
|
||||||
## Hardware Requirements
|
|
||||||
|
|
||||||
### Required Components
|
|
||||||
- **Raspberry Pi** (or compatible SBC)
|
|
||||||
- **IT8951 E-ink Display Controller** (1872×1404 resolution)
|
|
||||||
- **FT5316 Capacitive Touch Panel**
|
|
||||||
|
|
||||||
### Optional Components
|
|
||||||
- **BMA400 Accelerometer** - Auto-rotation based on device orientation
|
|
||||||
- **PCF8523 RTC** - Timekeeping with battery backup
|
|
||||||
- **INA219 Power Monitor** - Battery level monitoring
|
|
||||||
|
|
||||||
## Software Installation
|
|
||||||
|
|
||||||
### 1. Install System Dependencies
|
|
||||||
|
|
||||||
On Raspberry Pi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update package list
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
sudo apt install -y python3-dev python3-pip python3-venv
|
|
||||||
sudo apt install -y i2c-tools python3-smbus
|
|
||||||
|
|
||||||
# Enable I2C and SPI interfaces
|
|
||||||
sudo raspi-config
|
|
||||||
# Navigate to: Interface Options -> Enable I2C and SPI
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Clone and Set Up DReader Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the application
|
|
||||||
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
|
||||||
cd dreader-application
|
|
||||||
|
|
||||||
# Initialize and update submodules (includes dreader-hal)
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Install dreader-application
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install dreader-hal and Dependencies
|
|
||||||
|
|
||||||
The dreader-hal library has external driver dependencies in its `external/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dreader-hal in editable mode
|
|
||||||
pip install -e external/dreader-hal
|
|
||||||
|
|
||||||
# Install external driver libraries
|
|
||||||
cd external/dreader-hal/external
|
|
||||||
|
|
||||||
# Install each driver
|
|
||||||
pip install -e IT8951
|
|
||||||
pip install -e PyFTtxx6
|
|
||||||
pip install -e PyBMA400
|
|
||||||
pip install -e PyPCF8523
|
|
||||||
pip install -e pi_ina219
|
|
||||||
|
|
||||||
cd ../../.. # Back to dreader-application root
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Install Raspberry Pi GPIO (if on RPi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install RPi.GPIO spidev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hardware Wiring
|
|
||||||
|
|
||||||
### IT8951 E-ink Display (SPI)
|
|
||||||
| IT8951 Pin | Raspberry Pi Pin | 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 |
|
|
||||||
|
|
||||||
### FT5316 Touch Panel (I2C)
|
|
||||||
| FT5316 Pin | Raspberry Pi Pin | Description |
|
|
||||||
|------------|------------------|-------------|
|
|
||||||
| VCC | 3.3V (Pin 1) | Power supply |
|
|
||||||
| GND | GND (Pin 6) | Ground |
|
|
||||||
| SDA | GPIO 2 (Pin 3) | I2C Data |
|
|
||||||
| SCL | GPIO 3 (Pin 5) | I2C Clock |
|
|
||||||
| INT | GPIO 27 (Pin 13) | Interrupt (optional) |
|
|
||||||
|
|
||||||
### BMA400 Accelerometer (I2C) - Optional
|
|
||||||
| BMA400 Pin | Raspberry Pi Pin | Description |
|
|
||||||
|------------|------------------|-------------|
|
|
||||||
| VCC | 3.3V (Pin 1) | Power supply |
|
|
||||||
| GND | GND (Pin 6) | Ground |
|
|
||||||
| SDA | GPIO 2 (Pin 3) | I2C Data |
|
|
||||||
| SCL | GPIO 3 (Pin 5) | I2C Clock |
|
|
||||||
| I2C Address | 0x14 or 0x15 | Check your module |
|
|
||||||
|
|
||||||
### PCF8523 RTC (I2C) - Optional
|
|
||||||
| PCF8523 Pin | Raspberry Pi Pin | Description |
|
|
||||||
|------------|------------------|-------------|
|
|
||||||
| VCC | 3.3V (Pin 1) | Power supply |
|
|
||||||
| GND | GND (Pin 6) | Ground |
|
|
||||||
| SDA | GPIO 2 (Pin 3) | I2C Data |
|
|
||||||
| SCL | GPIO 3 (Pin 5) | I2C Clock |
|
|
||||||
| BAT | CR2032 Battery | Backup battery |
|
|
||||||
|
|
||||||
### INA219 Power Monitor (I2C) - Optional
|
|
||||||
| INA219 Pin | Raspberry Pi Pin | Description |
|
|
||||||
|------------|------------------|-------------|
|
|
||||||
| VCC | 3.3V (Pin 1) | Power supply |
|
|
||||||
| GND | GND (Pin 6) | Ground |
|
|
||||||
| SDA | GPIO 2 (Pin 3) | I2C Data |
|
|
||||||
| SCL | GPIO 3 (Pin 5) | I2C Clock |
|
|
||||||
| VIN+ | Battery + | Positive voltage sense |
|
|
||||||
| VIN- | Shunt resistor | Through shunt to load |
|
|
||||||
|
|
||||||
**Note**: Multiple I2C devices can share the same SDA/SCL pins. Ensure each has a unique I2C address.
|
|
||||||
|
|
||||||
### GPIO Buttons (Optional)
|
|
||||||
|
|
||||||
Physical buttons for navigation:
|
|
||||||
|
|
||||||
| Button Function | GPIO Pin | Connection |
|
|
||||||
|----------------|----------|------------|
|
|
||||||
| Previous Page | GPIO 22 | Button between GPIO 22 and GND |
|
|
||||||
| Next Page | GPIO 27 | Button between GPIO 27 and GND |
|
|
||||||
| Power Off | GPIO 21 | Button between GPIO 21 and GND |
|
|
||||||
|
|
||||||
**Wiring:**
|
|
||||||
- Connect one side of button to GPIO pin
|
|
||||||
- Connect other side to GND
|
|
||||||
- Internal pull-up resistors are enabled in software
|
|
||||||
- Button press pulls GPIO LOW (0V)
|
|
||||||
|
|
||||||
**Available GPIOs** (BCM numbering):
|
|
||||||
- Safe to use: 5-27 (except 14, 15 if using UART)
|
|
||||||
- Avoid: GPIO 2, 3 (I2C), GPIO 7-11 (SPI), GPIO 14, 15 (UART)
|
|
||||||
|
|
||||||
## Verify Hardware Connections
|
|
||||||
|
|
||||||
### Check I2C Devices
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scan I2C bus
|
|
||||||
i2cdetect -y 1
|
|
||||||
|
|
||||||
# Expected addresses (approximate):
|
|
||||||
# 0x38 - FT5316 touch panel
|
|
||||||
# 0x14 - BMA400 accelerometer
|
|
||||||
# 0x68 - PCF8523 RTC
|
|
||||||
# 0x40 - INA219 power monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check SPI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List SPI devices
|
|
||||||
ls /dev/spi*
|
|
||||||
# Should show: /dev/spidev0.0 /dev/spidev0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important: VCOM Voltage
|
|
||||||
|
|
||||||
⚠️ **CRITICAL**: Each e-ink display has a unique VCOM voltage printed on a label (usually on the back).
|
|
||||||
|
|
||||||
- Check your display label for VCOM voltage (e.g., -2.06V, -1.98V, etc.)
|
|
||||||
- Pass this value to the HAL using the `--vcom` parameter
|
|
||||||
- Using incorrect VCOM can damage your display!
|
|
||||||
|
|
||||||
Example from label: `VCOM = -2.06V` → use `--vcom -2.06`
|
|
||||||
|
|
||||||
## Running on Hardware
|
|
||||||
|
|
||||||
### Recommended Method: Interactive Setup
|
|
||||||
|
|
||||||
The easiest way to get started is using the interactive setup script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Run setup (detects hardware, configures GPIO buttons, etc.)
|
|
||||||
sudo python3 setup_rpi.py
|
|
||||||
|
|
||||||
# 2. Run DReader using generated config
|
|
||||||
python examples/run_on_hardware_config.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The setup script will:
|
|
||||||
- Detect connected I2C devices (touch, accelerometer, RTC, power monitor)
|
|
||||||
- Enable I2C/SPI interfaces if needed
|
|
||||||
- Configure GPIO button mappings
|
|
||||||
- Set VCOM voltage
|
|
||||||
- Generate hardware_config.json
|
|
||||||
|
|
||||||
### Manual Configuration
|
|
||||||
|
|
||||||
Edit `hardware_config.json` to customize your hardware setup:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display": {
|
|
||||||
"width": 1872,
|
|
||||||
"height": 1404,
|
|
||||||
"vcom": -2.06
|
|
||||||
},
|
|
||||||
"gpio_buttons": {
|
|
||||||
"enabled": true,
|
|
||||||
"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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
```bash
|
|
||||||
python examples/run_on_hardware_config.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Command Line (No Config File)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activate virtual environment
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Run with correct VCOM voltage (CHECK YOUR DISPLAY LABEL!)
|
|
||||||
python examples/run_on_hardware.py /path/to/books --vcom -2.06
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Without Hardware (Virtual Display)
|
|
||||||
|
|
||||||
You can test the integration on your development machine using virtual display mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--virtual \
|
|
||||||
--no-orientation \
|
|
||||||
--no-rtc \
|
|
||||||
--no-power
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a Tkinter window simulating the e-ink display.
|
|
||||||
|
|
||||||
### Advanced Options
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Disable optional hardware components
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--vcom -2.06 \
|
|
||||||
--no-orientation \ # Disable accelerometer
|
|
||||||
--no-rtc \ # Disable RTC
|
|
||||||
--no-power # Disable battery monitor
|
|
||||||
|
|
||||||
# Show battery level periodically
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--vcom -2.06 \
|
|
||||||
--show-battery
|
|
||||||
|
|
||||||
# Custom battery capacity
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--vcom -2.06 \
|
|
||||||
--battery-capacity 5000 # mAh
|
|
||||||
|
|
||||||
# Enable verbose debug logging
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--vcom -2.06 \
|
|
||||||
--verbose
|
|
||||||
|
|
||||||
# Always start in library mode (ignore saved state)
|
|
||||||
python examples/run_on_hardware.py /path/to/books \
|
|
||||||
--vcom -2.06 \
|
|
||||||
--force-library
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full Options Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/run_on_hardware.py --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Touch Gestures
|
|
||||||
|
|
||||||
Once running, the following touch gestures are supported:
|
|
||||||
|
|
||||||
| Gesture | 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** | Show word definition (if implemented) |
|
|
||||||
| **Pinch In/Out** | Adjust font size |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Display Not Working
|
|
||||||
|
|
||||||
1. **Check VCOM voltage** - Must match label on display
|
|
||||||
2. **Check SPI connections** - Run `ls /dev/spi*`
|
|
||||||
3. **Check SPI permissions** - Add user to `spi` group: `sudo usermod -a -G spi $USER`
|
|
||||||
4. **Try virtual display mode** - Test software without hardware
|
|
||||||
|
|
||||||
### Touch Not Working
|
|
||||||
|
|
||||||
1. **Check I2C connections** - Run `i2cdetect -y 1`
|
|
||||||
2. **Check I2C permissions** - Add user to `i2c` group: `sudo usermod -a -G i2c $USER`
|
|
||||||
3. **Check touch panel I2C address** - Should be 0x38 for FT5316
|
|
||||||
4. **Calibrate touch** - See dreader-hal calibration docs
|
|
||||||
|
|
||||||
### Import Errors
|
|
||||||
|
|
||||||
If you see `ModuleNotFoundError` for drivers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure all external drivers are installed
|
|
||||||
cd external/dreader-hal/external
|
|
||||||
for dir in */; do pip install -e "$dir"; done
|
|
||||||
cd ../../..
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission Errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add user to required groups
|
|
||||||
sudo usermod -a -G spi,i2c,gpio $USER
|
|
||||||
|
|
||||||
# Log out and back in for changes to take effect
|
|
||||||
```
|
|
||||||
|
|
||||||
### Display Ghosting
|
|
||||||
|
|
||||||
E-ink displays can show ghosting (image retention). The HAL automatically performs full refreshes every 10 page turns, but you can force one:
|
|
||||||
|
|
||||||
- The `RefreshMode.FULL` is automatically triggered periodically
|
|
||||||
- Check dreader-hal documentation for manual refresh control
|
|
||||||
|
|
||||||
## Programmatic Usage
|
|
||||||
|
|
||||||
You can also use the hardware HAL programmatically in your own scripts:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
from dreader.hal_hardware import HardwareDisplayHAL
|
|
||||||
from dreader.main import DReaderApplication, AppConfig
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
# Create hardware HAL
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create application config
|
|
||||||
config = AppConfig(
|
|
||||||
display_hal=hal,
|
|
||||||
library_path="/home/pi/Books",
|
|
||||||
page_size=(1872, 1404),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and run application
|
|
||||||
app = DReaderApplication(config)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await hal.initialize()
|
|
||||||
await app.start()
|
|
||||||
|
|
||||||
# Main event loop
|
|
||||||
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())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
### E-ink Refresh Modes
|
|
||||||
|
|
||||||
The dreader-hal library automatically selects refresh modes:
|
|
||||||
|
|
||||||
- **Fast (DU mode)**: ~200ms - Used for text updates
|
|
||||||
- **Quality (GC16 mode)**: ~1000ms - Used for images
|
|
||||||
- **Full (INIT mode)**: ~1000ms - Used every 10 pages to clear ghosting
|
|
||||||
|
|
||||||
### Battery Life
|
|
||||||
|
|
||||||
With default settings:
|
|
||||||
- Active reading: ~10-20 hours
|
|
||||||
- Standby (display sleeping): ~1-2 weeks
|
|
||||||
- Enable low power mode for extended battery life
|
|
||||||
|
|
||||||
### Memory Usage
|
|
||||||
|
|
||||||
- Base application: ~30-50MB
|
|
||||||
- Per book: ~10-30MB (depends on book size)
|
|
||||||
- Ensure Raspberry Pi has at least 512MB RAM
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- See [README.md](README.md) for application features
|
|
||||||
- See [external/dreader-hal/README.md](external/dreader-hal/README.md) for HAL details
|
|
||||||
- See [examples/](examples/) for more usage examples
|
|
||||||
- Check dreader-hal documentation for touch calibration and advanced features
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For hardware-specific issues, check:
|
|
||||||
- [dreader-hal issues](https://gitea.tourolle.paris/dtourolle/dreader-hal/issues)
|
|
||||||
|
|
||||||
For application issues, check:
|
|
||||||
- [dreader-application issues](https://gitea.tourolle.paris/dtourolle/dreader-application/issues)
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 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,9 +8,11 @@
|
|||||||
|  | **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 library browsing, text highlighting, bookmarks, gesture support, overlays, 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 text highlighting, bookmarks, gesture support, 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.
|
||||||
|
|
||||||
@@ -18,12 +20,11 @@ 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
|
||||||
- 📋 **Unified Overlays** - Navigation (TOC + Bookmarks) and Settings overlays
|
- 📋 **TOC Overlay** - Interactive table of contents overlay with gesture support
|
||||||
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
||||||
|
|
||||||
### Text Interaction
|
### Text Interaction
|
||||||
@@ -39,7 +40,6 @@ 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
|
||||||
|
|
||||||
# Navigation Overlay (unified TOC + Bookmarks)
|
# TOC Overlay
|
||||||
overlay_image = reader.open_navigation_overlay() # Opens with tabs
|
overlay_image = reader.open_toc_overlay() # Returns composited image with TOC
|
||||||
reader.close_overlay()
|
reader.close_overlay()
|
||||||
reader.is_overlay_open()
|
reader.is_overlay_open()
|
||||||
```
|
```
|
||||||
@@ -236,84 +236,34 @@ 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 navigation overlay (from bottom 20% of screen)
|
# - SWIPE_UP: Open TOC overlay (from bottom 20% of screen)
|
||||||
# - SWIPE_DOWN: Close overlay or open settings (from top 20%)
|
# - SWIPE_DOWN: Close overlay
|
||||||
# - PINCH_IN/OUT: Font size adjustment
|
# - PINCH_IN/OUT: Font size adjustment
|
||||||
# - DRAG: Text selection
|
# - DRAG: Text selection
|
||||||
```
|
```
|
||||||
|
|
||||||
### Settings Persistence
|
### File Operations
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from dreader.state import StateManager
|
# Save current page to file
|
||||||
from pathlib import Path
|
reader.render_to_file("current_page.png")
|
||||||
|
|
||||||
# Initialize state manager
|
# Context manager (auto-saves position on close)
|
||||||
state_file = Path.home() / ".config" / "dreader" / "state.json"
|
with EbookReader(page_size=(800, 1000)) as reader:
|
||||||
state_manager = StateManager(state_file=state_file)
|
reader.load_epub("book.epub")
|
||||||
|
# ... use reader ...
|
||||||
# Load saved state
|
# Position automatically saved on exit
|
||||||
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/](examples/) directory for complete working examples:
|
Check out the `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
|
||||||
- **[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
|
- **[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
|
||||||
### 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
|
||||||
|
|
||||||
@@ -329,27 +279,6 @@ 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:
|
||||||
@@ -368,30 +297,6 @@ 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
|
||||||
@@ -435,9 +340,6 @@ 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
|
||||||
```
|
```
|
||||||
@@ -449,10 +351,6 @@ 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
|
||||||
@@ -469,78 +367,6 @@ 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 [HARDWARE_SETUP.md](HARDWARE_SETUP.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 [HARDWARE_PINOUT.md](HARDWARE_PINOUT.md) for pin assignments and [GPIO_BUTTONS.md](GPIO_BUTTONS.md) for button configuration.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [README.md](README.md) - This file, main project documentation
|
|
||||||
- [REQUIREMENTS.md](REQUIREMENTS.md) - Application requirements specification
|
|
||||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture and design details
|
|
||||||
- [HAL_IMPLEMENTATION_SPEC.md](HAL_IMPLEMENTATION_SPEC.md) - Hardware integration guide
|
|
||||||
|
|
||||||
## 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!
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<svg width="140" height="20" viewBox="0 0 140 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<title>interrogate: 99.7%</title>
|
|
||||||
<g transform="matrix(1,0,0,1,22,0)">
|
|
||||||
<g id="backgrounds" transform="matrix(1.32789,0,0,1,-22.3892,0)">
|
|
||||||
<rect x="0" y="0" width="71" height="20" style="fill:rgb(85,85,85);"/>
|
|
||||||
</g>
|
|
||||||
<rect x="71" y="0" width="47" height="20" data-interrogate="color" style="fill:#4c1"/>
|
|
||||||
<g transform="matrix(1.19746,0,0,1,-22.3744,-4.85723e-16)">
|
|
||||||
<rect x="0" y="0" width="118" height="20" style="fill:url(#_Linear1);"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
|
||||||
<text x="590" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="610">interrogate</text>
|
|
||||||
<text x="590" y="140" transform="scale(.1)" textLength="610">interrogate</text>
|
|
||||||
<text x="1160" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370" data-interrogate="result">99.7%</text>
|
|
||||||
<text x="1160" y="140" transform="scale(.1)" textLength="370" data-interrogate="result">99.7%</text>
|
|
||||||
</g>
|
|
||||||
<g id="logo-shadow" serif:id="logo shadow" transform="matrix(0.854876,0,0,0.854876,-6.73514,1.732)">
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="logo-pink" serif:id="logo pink" transform="matrix(0.854876,0,0,0.854876,-6.73514,0.877124)">
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
||||||
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.22465e-15,20,-20,1.22465e-15,0,0)">
|
|
||||||
<stop offset="0" style="stop-color:rgb(187,187,187);stop-opacity:0.1"/>
|
|
||||||
<stop offset="1" style="stop-color:black;stop-opacity:0.1"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="20" role="img" aria-label="coverage: failed"><title>coverage: failed</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="100" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="39" height="20" fill="#e05d44"/><rect width="100" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="290">failed</text><text x="795" y="140" transform="scale(.1)" fill="#fff" textLength="290">failed</text></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,207 +0,0 @@
|
|||||||
#!/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__).parent))
|
|
||||||
|
|
||||||
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__).parent / "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__).parent / "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)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Debug previous_page issue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 579 KiB After Width: | Height: | Size: 648 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 591 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 543 KiB |
@@ -24,8 +24,7 @@ from dreader.state import (
|
|||||||
OverlayState
|
OverlayState
|
||||||
)
|
)
|
||||||
from dreader.library import LibraryManager
|
from dreader.library import LibraryManager
|
||||||
from dreader.main import DReaderApplication, AppConfig
|
from dreader.overlay import OverlayManager
|
||||||
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -55,12 +54,6 @@ __all__ = [
|
|||||||
# Library
|
# Library
|
||||||
"LibraryManager",
|
"LibraryManager",
|
||||||
|
|
||||||
# Main application
|
# Overlay
|
||||||
"DReaderApplication",
|
"OverlayManager",
|
||||||
"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, create_highlight_from_query_result
|
from pyWebLayout.core.highlight import Highlight, HighlightColor
|
||||||
|
|
||||||
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=background_color,
|
border_color=(200, 200, 200),
|
||||||
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,13 +129,8 @@ 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 sub-applications
|
# Overlay management
|
||||||
self._overlay_subapps = {
|
self.overlay_manager = OverlayManager(page_size=page_size)
|
||||||
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:
|
||||||
@@ -255,13 +250,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._active_overlay:
|
if self.is_overlay_open() and self.overlay_manager._cached_base_page:
|
||||||
# Return the composited overlay from the sub-application
|
# Return the last composited overlay image
|
||||||
if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image:
|
# The overlay manager keeps this updated when settings change
|
||||||
return self._active_overlay.composite_overlay(
|
return self.overlay_manager.composite_overlay(
|
||||||
self._active_overlay._cached_base_page,
|
self.overlay_manager._cached_base_page,
|
||||||
self._active_overlay._cached_overlay_image
|
self.overlay_manager._cached_overlay_image
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = self.manager.get_current_page()
|
page = self.manager.get_current_page()
|
||||||
@@ -507,28 +502,7 @@ 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.
|
||||||
@@ -728,26 +702,211 @@ 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"""
|
||||||
Handle tap when overlay is open.
|
# For TOC overlay, use pyWebLayout link query to detect chapter clicks
|
||||||
|
if self.current_overlay_state == OverlayState.TOC:
|
||||||
|
# Query the overlay to see what was tapped
|
||||||
|
query_result = self.overlay_manager.query_overlay_pixel(x, y)
|
||||||
|
|
||||||
Delegates to the active overlay sub-application for handling.
|
# If query failed (tap outside overlay), close it
|
||||||
If the response indicates the overlay should be closed, closes it.
|
if not query_result:
|
||||||
"""
|
self.close_overlay()
|
||||||
if not self._active_overlay:
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
# 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, {})
|
||||||
|
|
||||||
# Delegate to the active overlay sub-application
|
# For settings overlay, handle setting adjustments
|
||||||
response = self._active_overlay.handle_tap(x, y)
|
elif self.current_overlay_state == OverlayState.SETTINGS:
|
||||||
|
# Query the overlay to see what was tapped
|
||||||
|
query_result = self.overlay_manager.query_overlay_pixel(x, y)
|
||||||
|
|
||||||
# If the response indicates overlay should be closed, close it
|
# If query failed (tap outside overlay), close it
|
||||||
if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED,
|
if not query_result:
|
||||||
ActionType.BOOKMARK_SELECTED):
|
self.close_overlay()
|
||||||
|
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, {})
|
||||||
|
|
||||||
return response
|
# For navigation overlay, handle tab switching, chapter/bookmark selection, and close
|
||||||
|
elif self.current_overlay_state == OverlayState.NAVIGATION:
|
||||||
|
# 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 not query_result:
|
||||||
|
self.close_overlay()
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# Parse "tab:tabname" format for tab switching
|
||||||
|
if link_target.startswith("tab:"):
|
||||||
|
tab_name = link_target.split(":", 1)[1]
|
||||||
|
# Switch to the selected tab
|
||||||
|
self.switch_navigation_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
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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.load_position(bookmark_name)
|
||||||
|
if page:
|
||||||
|
# Close overlay
|
||||||
|
self.close_overlay()
|
||||||
|
|
||||||
|
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":
|
||||||
|
self.close_overlay()
|
||||||
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
|
# Not an interactive element, close overlay
|
||||||
|
self.close_overlay()
|
||||||
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
|
# For other overlays, just close on any tap for now
|
||||||
|
self.close_overlay()
|
||||||
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -980,12 +1139,8 @@ class EbookReader:
|
|||||||
# Get chapters
|
# Get chapters
|
||||||
chapters = self.get_chapters()
|
chapters = self.get_chapters()
|
||||||
|
|
||||||
# Use the TOC sub-application
|
# Open overlay and get composited image
|
||||||
overlay_subapp = self._overlay_subapps[OverlayState.TOC]
|
result = self.overlay_manager.open_toc_overlay(chapters, base_page)
|
||||||
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
|
||||||
@@ -1010,22 +1165,15 @@ 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"
|
|
||||||
|
|
||||||
# Use the Settings sub-application
|
# Open overlay and get composited image
|
||||||
overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS]
|
result = self.overlay_manager.open_settings_overlay(
|
||||||
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
|
||||||
@@ -1034,12 +1182,29 @@ 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")
|
if not self.is_loaded():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get current page as base
|
||||||
|
base_page = self.get_current_page(include_highlights=False)
|
||||||
|
if not base_page:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get bookmarks
|
||||||
|
bookmark_names = self.list_saved_positions()
|
||||||
|
bookmarks = [
|
||||||
|
{"name": name, "position": f"Saved position"}
|
||||||
|
for name in bookmark_names
|
||||||
|
]
|
||||||
|
|
||||||
|
# Open overlay and get composited image
|
||||||
|
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
|
||||||
|
self.current_overlay_state = OverlayState.BOOKMARKS
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]:
|
def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
@@ -1072,17 +1237,13 @@ class EbookReader:
|
|||||||
for name in bookmark_names
|
for name in bookmark_names
|
||||||
]
|
]
|
||||||
|
|
||||||
# Use the Navigation sub-application
|
# Open overlay and get composited image
|
||||||
overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION]
|
result = self.overlay_manager.open_navigation_overlay(
|
||||||
result = overlay_subapp.open(
|
|
||||||
base_page,
|
|
||||||
chapters=chapters,
|
chapters=chapters,
|
||||||
bookmarks=bookmarks,
|
bookmarks=bookmarks,
|
||||||
|
base_page=base_page,
|
||||||
active_tab=active_tab
|
active_tab=active_tab
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update state
|
|
||||||
self._active_overlay = overlay_subapp
|
|
||||||
self.current_overlay_state = OverlayState.NAVIGATION
|
self.current_overlay_state = OverlayState.NAVIGATION
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1100,12 +1261,8 @@ class EbookReader:
|
|||||||
if self.current_overlay_state != OverlayState.NAVIGATION:
|
if self.current_overlay_state != OverlayState.NAVIGATION:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Delegate to the Navigation sub-application
|
result = self.overlay_manager.switch_navigation_tab(new_tab)
|
||||||
if isinstance(self._active_overlay, NavigationOverlay):
|
return result if result else self.get_current_page()
|
||||||
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]:
|
||||||
"""
|
"""
|
||||||
@@ -1117,12 +1274,7 @@ class EbookReader:
|
|||||||
if self.current_overlay_state == OverlayState.NONE:
|
if self.current_overlay_state == OverlayState.NONE:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Close the active overlay sub-application
|
result = self.overlay_manager.close_overlay()
|
||||||
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,8 +2,6 @@
|
|||||||
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
|
||||||
@@ -13,8 +11,6 @@ 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]]:
|
||||||
"""
|
"""
|
||||||
@@ -48,15 +44,10 @@ 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,
|
||||||
@@ -67,19 +58,12 @@ 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,
|
||||||
@@ -144,20 +128,15 @@ 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
|
||||||
@@ -167,7 +146,6 @@ 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
|
||||||
@@ -176,36 +154,26 @@ 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,10 +25,6 @@ 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:
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
"""
|
|
||||||
Hardware HAL implementation using dreader-hal library.
|
|
||||||
|
|
||||||
This module provides the HardwareDisplayHAL class that bridges the DReader
|
|
||||||
application HAL interface with the dreader-hal hardware abstraction layer.
|
|
||||||
|
|
||||||
The dreader-hal library provides complete e-ink display integration with:
|
|
||||||
- IT8951 e-ink display driver
|
|
||||||
- FT5xx6 capacitive touch sensor
|
|
||||||
- BMA400 accelerometer (orientation)
|
|
||||||
- PCF8523 RTC (timekeeping)
|
|
||||||
- INA219 power monitor (battery)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from dreader.hal_hardware import HardwareDisplayHAL
|
|
||||||
from dreader.main import DReaderApplication, AppConfig
|
|
||||||
|
|
||||||
# For real hardware
|
|
||||||
hal = HardwareDisplayHAL(width=800, height=1200, vcom=-2.0)
|
|
||||||
|
|
||||||
# For testing without hardware
|
|
||||||
hal = HardwareDisplayHAL(
|
|
||||||
width=800,
|
|
||||||
height=1200,
|
|
||||||
virtual_display=True,
|
|
||||||
enable_orientation=False,
|
|
||||||
enable_rtc=False,
|
|
||||||
enable_power_monitor=False
|
|
||||||
)
|
|
||||||
|
|
||||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
|
||||||
app = DReaderApplication(config)
|
|
||||||
|
|
||||||
await hal.initialize()
|
|
||||||
await app.start()
|
|
||||||
|
|
||||||
# Main loop
|
|
||||||
while app.is_running():
|
|
||||||
event = await hal.get_touch_event()
|
|
||||||
if event:
|
|
||||||
await app.handle_touch(event)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
await app.shutdown()
|
|
||||||
await hal.cleanup()
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
from pathlib import Path
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from .hal import DisplayHAL
|
|
||||||
from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Try to import GPIO button support (only available on Raspberry Pi)
|
|
||||||
try:
|
|
||||||
from .gpio_buttons import GPIOButtonHandler, load_button_config_from_dict
|
|
||||||
GPIO_BUTTONS_AVAILABLE = True
|
|
||||||
except (ImportError, RuntimeError) as e:
|
|
||||||
GPIO_BUTTONS_AVAILABLE = False
|
|
||||||
logger.debug(f"GPIO buttons not available: {e}")
|
|
||||||
|
|
||||||
# Import dreader-hal components
|
|
||||||
try:
|
|
||||||
from dreader_hal import (
|
|
||||||
EReaderDisplayHAL,
|
|
||||||
TouchEvent as HalTouchEvent,
|
|
||||||
GestureType as HalGestureType,
|
|
||||||
RefreshMode,
|
|
||||||
PowerStats,
|
|
||||||
Orientation
|
|
||||||
)
|
|
||||||
DREADER_HAL_AVAILABLE = True
|
|
||||||
except ImportError as e:
|
|
||||||
DREADER_HAL_AVAILABLE = False
|
|
||||||
_import_error = e
|
|
||||||
|
|
||||||
|
|
||||||
# Gesture type mapping between dreader-hal and dreader-application
|
|
||||||
GESTURE_TYPE_MAP = {
|
|
||||||
HalGestureType.TAP: AppGestureType.TAP,
|
|
||||||
HalGestureType.LONG_PRESS: AppGestureType.LONG_PRESS,
|
|
||||||
HalGestureType.SWIPE_LEFT: AppGestureType.SWIPE_LEFT,
|
|
||||||
HalGestureType.SWIPE_RIGHT: AppGestureType.SWIPE_RIGHT,
|
|
||||||
HalGestureType.SWIPE_UP: AppGestureType.SWIPE_UP,
|
|
||||||
HalGestureType.SWIPE_DOWN: AppGestureType.SWIPE_DOWN,
|
|
||||||
HalGestureType.PINCH_IN: AppGestureType.PINCH_IN,
|
|
||||||
HalGestureType.PINCH_OUT: AppGestureType.PINCH_OUT,
|
|
||||||
HalGestureType.DRAG_START: AppGestureType.DRAG_START,
|
|
||||||
HalGestureType.DRAG_MOVE: AppGestureType.DRAG_MOVE,
|
|
||||||
HalGestureType.DRAG_END: AppGestureType.DRAG_END,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class HardwareDisplayHAL(DisplayHAL):
|
|
||||||
"""
|
|
||||||
Hardware HAL implementation using dreader-hal library.
|
|
||||||
|
|
||||||
This class adapts the dreader-hal EReaderDisplayHAL to work with the
|
|
||||||
DReader application's DisplayHAL interface.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Display width in pixels (default 1872)
|
|
||||||
height: Display height in pixels (default 1404)
|
|
||||||
vcom: E-ink VCOM voltage (default -2.0, check device label!)
|
|
||||||
spi_hz: SPI clock frequency (default 24MHz)
|
|
||||||
virtual_display: Use virtual display for testing (default False)
|
|
||||||
auto_sleep_display: Auto-sleep display after updates (default True)
|
|
||||||
enable_orientation: Enable orientation sensing (default True)
|
|
||||||
enable_rtc: Enable RTC timekeeping (default True)
|
|
||||||
enable_power_monitor: Enable battery monitoring (default True)
|
|
||||||
shunt_ohms: Power monitor shunt resistor (default 0.1)
|
|
||||||
battery_capacity_mah: Battery capacity in mAh (default 3000)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# For real hardware (Raspberry Pi with e-ink display)
|
|
||||||
hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0)
|
|
||||||
|
|
||||||
# For testing on development machine
|
|
||||||
hal = HardwareDisplayHAL(
|
|
||||||
width=1872,
|
|
||||||
height=1404,
|
|
||||||
virtual_display=True,
|
|
||||||
enable_orientation=False,
|
|
||||||
enable_rtc=False,
|
|
||||||
enable_power_monitor=False
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
width: int = 1872,
|
|
||||||
height: int = 1404,
|
|
||||||
vcom: float = -2.0,
|
|
||||||
spi_hz: int = 24_000_000,
|
|
||||||
virtual_display: bool = False,
|
|
||||||
auto_sleep_display: bool = True,
|
|
||||||
enable_orientation: bool = True,
|
|
||||||
enable_rtc: bool = True,
|
|
||||||
enable_power_monitor: bool = True,
|
|
||||||
shunt_ohms: float = 0.1,
|
|
||||||
battery_capacity_mah: float = 3000,
|
|
||||||
gpio_config: Optional[dict] = None,
|
|
||||||
config_file: Optional[str] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize hardware HAL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
gpio_config: GPIO button configuration dict (optional)
|
|
||||||
config_file: Path to hardware_config.json file (optional, defaults to "hardware_config.json")
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImportError: If dreader-hal library is not installed
|
|
||||||
"""
|
|
||||||
if not DREADER_HAL_AVAILABLE:
|
|
||||||
raise ImportError(
|
|
||||||
f"dreader-hal library is required for HardwareDisplayHAL.\n"
|
|
||||||
f"Install with: pip install -e external/dreader-hal\n"
|
|
||||||
f"Original error: {_import_error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}")
|
|
||||||
logger.info(f" VCOM: {vcom}V")
|
|
||||||
logger.info(f" Virtual display: {virtual_display}")
|
|
||||||
logger.info(f" Orientation: {enable_orientation}")
|
|
||||||
logger.info(f" RTC: {enable_rtc}")
|
|
||||||
logger.info(f" Power monitor: {enable_power_monitor}")
|
|
||||||
|
|
||||||
# Create the underlying dreader-hal implementation
|
|
||||||
self.hal = EReaderDisplayHAL(
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
vcom=vcom,
|
|
||||||
spi_hz=spi_hz,
|
|
||||||
virtual_display=virtual_display,
|
|
||||||
auto_sleep_display=auto_sleep_display,
|
|
||||||
enable_orientation=enable_orientation,
|
|
||||||
enable_rtc=enable_rtc,
|
|
||||||
enable_power_monitor=enable_power_monitor,
|
|
||||||
shunt_ohms=shunt_ohms,
|
|
||||||
battery_capacity_mah=battery_capacity_mah,
|
|
||||||
)
|
|
||||||
|
|
||||||
# GPIO button handler (optional)
|
|
||||||
self.gpio_handler: Optional[GPIOButtonHandler] = None
|
|
||||||
|
|
||||||
# Load GPIO config from file if specified
|
|
||||||
if config_file or gpio_config is None:
|
|
||||||
config_path = Path(config_file or "hardware_config.json")
|
|
||||||
if config_path.exists():
|
|
||||||
try:
|
|
||||||
with open(config_path, 'r') as f:
|
|
||||||
full_config = json.load(f)
|
|
||||||
gpio_config = full_config
|
|
||||||
logger.info(f"Loaded hardware config from {config_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not load hardware config from {config_path}: {e}")
|
|
||||||
|
|
||||||
# Initialize GPIO buttons if configured
|
|
||||||
if gpio_config and GPIO_BUTTONS_AVAILABLE:
|
|
||||||
try:
|
|
||||||
self.gpio_handler = load_button_config_from_dict(
|
|
||||||
gpio_config,
|
|
||||||
screen_width=width,
|
|
||||||
screen_height=height
|
|
||||||
)
|
|
||||||
if self.gpio_handler:
|
|
||||||
logger.info("GPIO button handler created")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not initialize GPIO buttons: {e}")
|
|
||||||
elif gpio_config and not GPIO_BUTTONS_AVAILABLE:
|
|
||||||
logger.info("GPIO buttons configured but RPi.GPIO not available (not on Raspberry Pi)")
|
|
||||||
|
|
||||||
self._initialized = False
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
"""
|
|
||||||
Initialize all hardware components.
|
|
||||||
|
|
||||||
This initializes:
|
|
||||||
- E-ink display controller
|
|
||||||
- Touch sensor
|
|
||||||
- Accelerometer (if enabled)
|
|
||||||
- RTC (if enabled)
|
|
||||||
- Power monitor (if enabled)
|
|
||||||
- GPIO buttons (if configured)
|
|
||||||
"""
|
|
||||||
if self._initialized:
|
|
||||||
logger.warning("Hardware HAL already initialized")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Initializing hardware components...")
|
|
||||||
await self.hal.initialize()
|
|
||||||
|
|
||||||
# Initialize GPIO buttons
|
|
||||||
if self.gpio_handler:
|
|
||||||
logger.info("Initializing GPIO buttons...")
|
|
||||||
await self.gpio_handler.initialize()
|
|
||||||
|
|
||||||
self._initialized = True
|
|
||||||
logger.info("Hardware HAL initialized successfully")
|
|
||||||
|
|
||||||
async def cleanup(self):
|
|
||||||
"""Clean up all hardware resources."""
|
|
||||||
if not self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Cleaning up hardware HAL")
|
|
||||||
|
|
||||||
# Clean up GPIO buttons
|
|
||||||
if self.gpio_handler:
|
|
||||||
logger.info("Cleaning up GPIO buttons...")
|
|
||||||
await self.gpio_handler.cleanup()
|
|
||||||
|
|
||||||
await self.hal.cleanup()
|
|
||||||
self._initialized = False
|
|
||||||
logger.info("Hardware HAL cleaned up")
|
|
||||||
|
|
||||||
async def show_image(self, image: Image.Image):
|
|
||||||
"""
|
|
||||||
Display a PIL Image on the e-ink screen.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image: PIL Image to display
|
|
||||||
|
|
||||||
The dreader-hal library handles:
|
|
||||||
- Format conversion (RGB -> grayscale)
|
|
||||||
- Dithering for e-ink
|
|
||||||
- Refresh mode selection (auto, fast, quality, full)
|
|
||||||
- Orientation rotation (if enabled)
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
logger.warning("Hardware HAL not initialized, initializing now...")
|
|
||||||
await self.initialize()
|
|
||||||
|
|
||||||
logger.debug(f"Displaying image: {image.size} {image.mode}")
|
|
||||||
await self.hal.show_image(image)
|
|
||||||
|
|
||||||
async def get_touch_event(self) -> Optional[AppTouchEvent]:
|
|
||||||
"""
|
|
||||||
Get the next touch event from hardware (touch sensor or GPIO buttons).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TouchEvent if available, None if no event
|
|
||||||
|
|
||||||
The dreader-hal library handles gesture classification:
|
|
||||||
- TAP: Quick tap (< 30px movement, < 300ms)
|
|
||||||
- LONG_PRESS: Hold (< 30px movement, >= 500ms)
|
|
||||||
- SWIPE_*: Directional swipes (>= 30px movement)
|
|
||||||
- PINCH_IN/OUT: Two-finger pinch gestures
|
|
||||||
|
|
||||||
GPIO buttons are also polled and generate TouchEvent objects.
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check GPIO buttons first (they're more responsive)
|
|
||||||
if self.gpio_handler:
|
|
||||||
button_event = await self.gpio_handler.get_button_event()
|
|
||||||
if button_event:
|
|
||||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
|
||||||
return button_event
|
|
||||||
|
|
||||||
# Get event from dreader-hal touch sensor
|
|
||||||
hal_event = await self.hal.get_touch_event()
|
|
||||||
|
|
||||||
if hal_event is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
|
||||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
|
||||||
|
|
||||||
if app_gesture is None:
|
|
||||||
logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
|
||||||
|
|
||||||
return AppTouchEvent(
|
|
||||||
gesture=app_gesture,
|
|
||||||
x=hal_event.x,
|
|
||||||
y=hal_event.y
|
|
||||||
)
|
|
||||||
|
|
||||||
async def set_brightness(self, level: int):
|
|
||||||
"""
|
|
||||||
Set display brightness.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
level: Brightness level (0-10)
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Basic IT8951 e-ink displays don't have brightness control.
|
|
||||||
This is a no-op unless frontlight hardware is connected.
|
|
||||||
"""
|
|
||||||
if not 0 <= level <= 10:
|
|
||||||
raise ValueError("Brightness must be 0-10")
|
|
||||||
|
|
||||||
logger.debug(f"Setting brightness to {level}")
|
|
||||||
await self.hal.set_brightness(level)
|
|
||||||
|
|
||||||
# ========== Extended Methods (Hardware-Specific Features) ==========
|
|
||||||
|
|
||||||
async def get_battery_level(self) -> float:
|
|
||||||
"""
|
|
||||||
Get battery percentage.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Battery level 0-100%, or 0.0 if power monitor unavailable
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
return await self.hal.get_battery_level()
|
|
||||||
|
|
||||||
async def get_power_stats(self) -> PowerStats:
|
|
||||||
"""
|
|
||||||
Get detailed power statistics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PowerStats with voltage, current, power, battery %, etc.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If power monitor not enabled
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
raise RuntimeError("Hardware HAL not initialized")
|
|
||||||
|
|
||||||
return await self.hal.get_power_stats()
|
|
||||||
|
|
||||||
async def is_low_battery(self, threshold: float = 20.0) -> bool:
|
|
||||||
"""
|
|
||||||
Check if battery is low.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
threshold: Battery percentage threshold (default 20%)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if battery below threshold, False otherwise
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return await self.hal.is_low_battery(threshold)
|
|
||||||
|
|
||||||
async def set_low_power_mode(self, enabled: bool):
|
|
||||||
"""
|
|
||||||
Enable/disable low power mode.
|
|
||||||
|
|
||||||
In low power mode:
|
|
||||||
- Display goes to sleep
|
|
||||||
- Touch polling rate reduced
|
|
||||||
- Sensors put to low power
|
|
||||||
|
|
||||||
Args:
|
|
||||||
enabled: True to enable low power mode
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Setting low power mode: {enabled}")
|
|
||||||
await self.hal.set_low_power_mode(enabled)
|
|
||||||
|
|
||||||
async def enable_orientation_monitoring(self):
|
|
||||||
"""
|
|
||||||
Start monitoring device orientation changes.
|
|
||||||
|
|
||||||
When orientation changes, display auto-rotates.
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Enabling orientation monitoring")
|
|
||||||
await self.hal.enable_orientation_monitoring()
|
|
||||||
|
|
||||||
async def disable_orientation_monitoring(self):
|
|
||||||
"""Stop monitoring orientation changes."""
|
|
||||||
if not self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Disabling orientation monitoring")
|
|
||||||
await self.hal.disable_orientation_monitoring()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_orientation(self) -> Optional[Orientation]:
|
|
||||||
"""Get current device orientation."""
|
|
||||||
if not self._initialized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.hal.current_orientation
|
|
||||||
|
|
||||||
@property
|
|
||||||
def refresh_count(self) -> int:
|
|
||||||
"""Get number of display refreshes since initialization."""
|
|
||||||
if not self._initialized:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return self.hal.refresh_count
|
|
||||||
|
|
||||||
async def get_datetime(self):
|
|
||||||
"""
|
|
||||||
Get current date/time from RTC.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
struct_time with current date and time, or None if RTC unavailable
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.hal.get_datetime()
|
|
||||||
|
|
||||||
async def set_datetime(self, dt):
|
|
||||||
"""
|
|
||||||
Set the RTC date/time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dt: time.struct_time object with date and time to set
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If RTC not enabled
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
raise RuntimeError("Hardware HAL not initialized")
|
|
||||||
|
|
||||||
await self.hal.set_datetime(dt)
|
|
||||||
|
|
||||||
# ========== Accelerometer Tilt Detection ==========
|
|
||||||
|
|
||||||
def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool:
|
|
||||||
"""
|
|
||||||
Load accelerometer calibration from file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_path: Path to calibration JSON file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if calibration loaded successfully, False otherwise
|
|
||||||
"""
|
|
||||||
config_file = Path(config_path)
|
|
||||||
if not config_file.exists():
|
|
||||||
logger.warning(f"Accelerometer calibration file not found: {config_path}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
# Load up vector
|
|
||||||
up = config.get("up_vector", {})
|
|
||||||
self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
|
|
||||||
|
|
||||||
# Load thresholds
|
|
||||||
self.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
|
|
||||||
self.accel_debounce_time = config.get("debounce_time", 0.5)
|
|
||||||
|
|
||||||
# State tracking
|
|
||||||
self.accel_last_tilt_time = 0
|
|
||||||
|
|
||||||
logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}")
|
|
||||||
logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)")
|
|
||||||
logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading accelerometer calibration: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_tilt_gesture(self) -> Optional[AppTouchEvent]:
|
|
||||||
"""
|
|
||||||
Check accelerometer for tilt gestures.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected,
|
|
||||||
None otherwise
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Requires accelerometer calibration to be loaded first via
|
|
||||||
load_accelerometer_calibration()
|
|
||||||
"""
|
|
||||||
if not self._initialized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not self.hal.orientation:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not hasattr(self, 'accel_up_vector'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get current acceleration
|
|
||||||
try:
|
|
||||||
ax, ay, az = await self.hal.orientation.get_acceleration()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error reading accelerometer: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check debounce
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - self.accel_last_tilt_time < self.accel_debounce_time:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate angle between current gravity and calibrated "up" vector
|
|
||||||
# Gravity vector is the acceleration (pointing down)
|
|
||||||
gx, gy, gz = ax, ay, az
|
|
||||||
|
|
||||||
# Normalize gravity
|
|
||||||
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
|
|
||||||
if g_mag < 0.1:
|
|
||||||
return None
|
|
||||||
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
|
|
||||||
|
|
||||||
# Normalize up vector
|
|
||||||
ux, uy, uz = self.accel_up_vector
|
|
||||||
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
|
||||||
if u_mag < 0.1:
|
|
||||||
return None
|
|
||||||
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
|
|
||||||
|
|
||||||
# Calculate tilt: project gravity onto the "forward/backward" axis
|
|
||||||
# Forward/backward axis is perpendicular to up vector
|
|
||||||
# We'll use the component of gravity that's perpendicular to the up vector
|
|
||||||
|
|
||||||
# Dot product: component of gravity along up vector
|
|
||||||
dot_up = gx * ux + gy * uy + gz * uz
|
|
||||||
|
|
||||||
# Component of gravity perpendicular to up vector
|
|
||||||
perp_x = gx - dot_up * ux
|
|
||||||
perp_y = gy - dot_up * uy
|
|
||||||
perp_z = gz - dot_up * uz
|
|
||||||
|
|
||||||
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
|
|
||||||
|
|
||||||
# Angle from vertical (in radians)
|
|
||||||
tilt_angle = math.atan2(perp_mag, abs(dot_up))
|
|
||||||
|
|
||||||
logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)")
|
|
||||||
|
|
||||||
# Check if tilted beyond threshold
|
|
||||||
if tilt_angle < self.accel_tilt_threshold:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Determine direction: forward or backward
|
|
||||||
# We need to determine which direction the device is tilted
|
|
||||||
# Use the sign of the perpendicular component along a reference axis
|
|
||||||
|
|
||||||
# For simplicity, we'll use the projection onto the original up vector's
|
|
||||||
# perpendicular plane. If we tilt "forward", the gravity vector should
|
|
||||||
# rotate in a specific direction.
|
|
||||||
|
|
||||||
# Calculate which direction: check if tilting away from or toward the up vector
|
|
||||||
# If dot_up is decreasing (device tilting away from up), that's "forward"
|
|
||||||
# If dot_up is increasing (device tilting back toward up), that's "backward"
|
|
||||||
|
|
||||||
# Actually, a simpler approach: check the direction of the perpendicular component
|
|
||||||
# relative to a reference direction in the plane
|
|
||||||
|
|
||||||
# Let's define forward as tilting in the direction that increases the
|
|
||||||
# y-component of acceleration (assuming standard orientation)
|
|
||||||
# This is device-specific and may need adjustment
|
|
||||||
|
|
||||||
# For now, use a simple heuristic: forward = positive perpendicular y component
|
|
||||||
if perp_y > 0:
|
|
||||||
gesture = AppGestureType.TILT_FORWARD
|
|
||||||
else:
|
|
||||||
gesture = AppGestureType.TILT_BACKWARD
|
|
||||||
|
|
||||||
# Update debounce timer
|
|
||||||
self.accel_last_tilt_time = current_time
|
|
||||||
|
|
||||||
logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)")
|
|
||||||
|
|
||||||
# Return gesture at center of screen (x, y don't matter for tilt)
|
|
||||||
return AppTouchEvent(
|
|
||||||
gesture=gesture,
|
|
||||||
x=self.width // 2,
|
|
||||||
y=self.height // 2,
|
|
||||||
timestamp_ms=current_time * 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_event(self) -> Optional[AppTouchEvent]:
|
|
||||||
"""
|
|
||||||
Get the next event from any input source (GPIO, touch, or accelerometer).
|
|
||||||
|
|
||||||
This is a convenience method that polls all input sources in a single call.
|
|
||||||
Priority order: GPIO buttons > touch sensor > accelerometer tilt
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TouchEvent from GPIO, touch sensor, or accelerometer, or None if no event
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
while running:
|
|
||||||
event = await hal.get_event()
|
|
||||||
if event:
|
|
||||||
handle_gesture(event)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
"""
|
|
||||||
# Check GPIO buttons first (most responsive)
|
|
||||||
if self.gpio_handler:
|
|
||||||
button_event = await self.gpio_handler.get_button_event()
|
|
||||||
if button_event:
|
|
||||||
logger.info(f"GPIO button event: {button_event.gesture.value}")
|
|
||||||
return button_event
|
|
||||||
|
|
||||||
# Check touch sensor (second priority)
|
|
||||||
# Get event from dreader-hal touch sensor directly
|
|
||||||
hal_event = await self.hal.get_touch_event()
|
|
||||||
if hal_event is not None:
|
|
||||||
# Convert from dreader-hal TouchEvent to application TouchEvent
|
|
||||||
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
|
|
||||||
if app_gesture is not None:
|
|
||||||
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
|
|
||||||
return AppTouchEvent(
|
|
||||||
gesture=app_gesture,
|
|
||||||
x=hal_event.x,
|
|
||||||
y=hal_event.y
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check accelerometer tilt (lowest priority)
|
|
||||||
if hasattr(self, 'accel_up_vector'):
|
|
||||||
tilt_event = await self.get_tilt_gesture()
|
|
||||||
if tilt_event:
|
|
||||||
return tilt_event
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
"""
|
|
||||||
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,10 +81,6 @@ 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, {})
|
||||||
|
|
||||||
@@ -238,33 +234,36 @@ class GestureRouter:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||||
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
|
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
|
||||||
# Open navigation overlay from anywhere on screen
|
# Check if swipe started from bottom 20% of screen
|
||||||
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
|
bottom_threshold = self.reader.page_size[1] * 0.8
|
||||||
if overlay_image:
|
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
if y >= bottom_threshold:
|
||||||
"overlay_type": "navigation",
|
# Open TOC overlay
|
||||||
"active_tab": "contents",
|
overlay_image = self.reader.open_toc_overlay()
|
||||||
"chapters": self.reader.get_chapters()
|
if overlay_image:
|
||||||
})
|
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 (only from top 20% of screen)"""
|
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
|
||||||
# Only open settings overlay if swipe starts from top 20% of screen
|
# Check if swipe started 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, {})
|
|
||||||
|
|
||||||
overlay_image = self.reader.open_settings_overlay()
|
if y <= top_threshold:
|
||||||
if overlay_image:
|
# Open settings overlay
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
overlay_image = self.reader.open_settings_overlay()
|
||||||
"overlay_type": "settings",
|
if overlay_image:
|
||||||
"font_scale": self.reader.base_font_scale,
|
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||||
"line_spacing": self.reader.page_style.line_spacing,
|
"overlay_type": "settings",
|
||||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
"font_scale": self.reader.base_font_scale,
|
||||||
})
|
"line_spacing": self.reader.page_style.line_spacing,
|
||||||
|
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
||||||
|
})
|
||||||
|
|
||||||
return GestureResponse(ActionType.NONE, {})
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -211,7 +210,6 @@ 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:
|
||||||
@@ -220,15 +218,6 @@ 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>
|
||||||
@@ -248,66 +237,50 @@ 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="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
<a href="setting:font_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
<a href="setting:font_increase" style="text-decoration: none; color: #000;">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="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000;">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="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000;">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="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin: 20px 0;">
|
<div style="margin: 20px 0;">
|
||||||
<p style="margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
||||||
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px; display: block; padding: 15px;">◄ Back to Library</a>
|
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">◄ Back to Library</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,12 +294,7 @@ def generate_settings_overlay(
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def generate_toc_overlay(
|
def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -> str:
|
||||||
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.
|
||||||
|
|
||||||
@@ -335,32 +303,21 @@ def generate_toc_overlay(
|
|||||||
- 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(toc_paginated):
|
for i, chapter in enumerate(chapters):
|
||||||
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'{chapter_num}. {title}'
|
link_text = f'{i+1}. {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'{chapter_num}. {title} ' # Extra spaces for padding
|
link_text = f'{i+1}. {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; '
|
||||||
@@ -369,26 +326,6 @@ def generate_toc_overlay(
|
|||||||
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>
|
||||||
@@ -408,12 +345,10 @@ def generate_toc_overlay(
|
|||||||
{len(chapters)} chapters
|
{len(chapters)} chapters
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="min-height: 400px;">
|
<div style="max-height: 600px; overflow-y: auto;">
|
||||||
{"".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
|
||||||
@@ -573,17 +508,13 @@ def generate_navigation_overlay(
|
|||||||
chapters: List[Dict],
|
chapters: List[Dict],
|
||||||
bookmarks: List[Dict],
|
bookmarks: List[Dict],
|
||||||
active_tab: str = "contents",
|
active_tab: str = "contents",
|
||||||
page_size: tuple = (800, 1200),
|
page_size: tuple = (800, 1200)
|
||||||
toc_page: int = 0,
|
|
||||||
toc_items_per_page: int = 10,
|
|
||||||
bookmarks_page: int = 0
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate HTML for the unified navigation overlay with Contents and Bookmarks tabs.
|
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.
|
This combines TOC and Bookmarks into a single overlay with tab switching.
|
||||||
Tabs are clickable links that switch between contents (tab:contents) and bookmarks (tab:bookmarks).
|
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:
|
Args:
|
||||||
chapters: List of chapter dictionaries with keys:
|
chapters: List of chapter dictionaries with keys:
|
||||||
@@ -594,50 +525,35 @@ def generate_navigation_overlay(
|
|||||||
- position: Position info (optional)
|
- position: Position info (optional)
|
||||||
active_tab: Which tab to show ("contents" or "bookmarks")
|
active_tab: Which tab to show ("contents" or "bookmarks")
|
||||||
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 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:
|
Returns:
|
||||||
HTML string for navigation overlay with tab switching and pagination
|
HTML string for navigation overlay with tab switching
|
||||||
"""
|
"""
|
||||||
# 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
|
# Build chapter list items with clickable links
|
||||||
chapter_items = []
|
chapter_items = []
|
||||||
for i, chapter in enumerate(toc_paginated):
|
for i, chapter in enumerate(chapters):
|
||||||
title = chapter["title"]
|
title = chapter["title"]
|
||||||
# Use original chapter number (not the paginated index)
|
link_text = f'{i+1}. {title}'
|
||||||
chapter_num = toc_start + i + 1
|
|
||||||
link_text = f'{chapter_num}. {title}'
|
|
||||||
if len(title) <= 2:
|
if len(title) <= 2:
|
||||||
link_text = f'{chapter_num}. {title} ' # Extra spaces for padding
|
link_text = f'{i+1}. {title} ' # Extra spaces for padding
|
||||||
|
|
||||||
chapter_items.append(
|
chapter_items.append(
|
||||||
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||||
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
f'border-left: 3px solid #000;">'
|
||||||
|
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
|
||||||
f'{link_text}</a></p>'
|
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
|
# Build bookmark list items with clickable links
|
||||||
bookmark_items = []
|
bookmark_items = []
|
||||||
for bookmark in bookmarks_paginated:
|
for bookmark in bookmarks:
|
||||||
name = bookmark['name']
|
name = bookmark['name']
|
||||||
position_text = bookmark.get('position', 'Saved position')
|
position_text = bookmark.get('position', 'Saved position')
|
||||||
|
|
||||||
bookmark_items.append(
|
bookmark_items.append(
|
||||||
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||||
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
f'border-left: 3px solid #000;">'
|
||||||
|
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block;">'
|
||||||
f'<span style="font-weight: bold; display: block;">{name}</span>'
|
f'<span style="font-weight: bold; display: block;">{name}</span>'
|
||||||
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
|
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
|
||||||
f'</a></p>'
|
f'</a></p>'
|
||||||
@@ -654,46 +570,6 @@ def generate_navigation_overlay(
|
|||||||
chapters_html = ''.join(chapter_items) if chapter_items else '<p style="padding: 20px; text-align: center; color: #999;">No chapters available</p>'
|
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>'
|
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'''
|
html = f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -726,10 +602,9 @@ def generate_navigation_overlay(
|
|||||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||||
{len(chapters)} chapters
|
{len(chapters)} chapters
|
||||||
</p>
|
</p>
|
||||||
<div style="min-height: 400px;">
|
<div style="overflow-y: auto; max-height: calc(100vh - 200px);">
|
||||||
{chapters_html}
|
{chapters_html}
|
||||||
</div>
|
</div>
|
||||||
{toc_pagination}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bookmarks Tab Content -->
|
<!-- Bookmarks Tab Content -->
|
||||||
@@ -741,10 +616,9 @@ def generate_navigation_overlay(
|
|||||||
border-bottom: 2px solid #ccc; font-size: 13px;">
|
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||||
{len(bookmarks)} saved
|
{len(bookmarks)} saved
|
||||||
</p>
|
</p>
|
||||||
<div style="min-height: 400px;">
|
<div style="overflow-y: auto; max-height: calc(100vh - 200px);">
|
||||||
{bookmarks_html}
|
{bookmarks_html}
|
||||||
</div>
|
</div>
|
||||||
{bookmarks_pagination}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Close Button (bottom right) -->
|
<!-- Close Button (bottom right) -->
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ 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
|
||||||
@@ -31,8 +29,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -49,8 +45,7 @@ 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.
|
||||||
@@ -59,11 +54,9 @@ 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:
|
||||||
@@ -82,7 +75,6 @@ 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:
|
||||||
@@ -104,34 +96,19 @@ 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
|
||||||
cache_start = time.time()
|
for book in self.books:
|
||||||
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
|
||||||
|
|
||||||
@@ -172,13 +149,12 @@ 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, page: Optional[int] = None) -> Table:
|
def create_library_table(self, books: Optional[List[Dict]] = None) -> Table:
|
||||||
"""
|
"""
|
||||||
Create interactive library table with book covers and info in 2-column grid.
|
Create interactive library table with book covers and info.
|
||||||
|
|
||||||
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
|
||||||
@@ -186,132 +162,82 @@ 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 = []
|
||||||
|
|
||||||
# Calculate pagination
|
print(f"Creating library table with {len(books)} books...")
|
||||||
total_pages = (len(books) + self.books_per_page - 1) // self.books_per_page
|
|
||||||
start_idx = page * self.books_per_page
|
|
||||||
end_idx = min(start_idx + self.books_per_page, len(books))
|
|
||||||
page_books = books[start_idx:end_idx]
|
|
||||||
|
|
||||||
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
|
# Create table
|
||||||
|
table = Table(caption="My Library", style=Font(font_size=18, weight="bold"))
|
||||||
|
|
||||||
# Create table with caption showing page info
|
# Add books as rows
|
||||||
caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library"
|
for i, book in enumerate(books):
|
||||||
table = Table(caption=caption_text, style=Font(font_size=18, weight="bold"))
|
row = table.create_row("body")
|
||||||
|
|
||||||
# Add books in 2-column grid (each pair of books gets 2 rows: covers then details)
|
# Cover cell with interactive image
|
||||||
for i in range(0, len(page_books), 2):
|
cover_cell = row.create_cell()
|
||||||
# Row 1: Covers for this pair
|
cover_path = book.get('cover_path')
|
||||||
cover_row = table.create_row("body")
|
book_path = book['path']
|
||||||
|
|
||||||
# Add first book's cover (left column)
|
# Create callback that returns book path
|
||||||
self._add_book_cover(cover_row, page_books[i])
|
callback = lambda point, path=book_path: path
|
||||||
|
|
||||||
# Add second book's cover (right column) if it exists
|
|
||||||
if i + 1 < len(page_books):
|
|
||||||
self._add_book_cover(cover_row, page_books[i + 1])
|
|
||||||
else:
|
|
||||||
# Add empty cell if odd number of books
|
|
||||||
cover_row.create_cell()
|
|
||||||
|
|
||||||
# Row 2: Details for this pair
|
|
||||||
details_row = table.create_row("body")
|
|
||||||
|
|
||||||
# Add first book's details (left column)
|
|
||||||
self._add_book_details(details_row, page_books[i])
|
|
||||||
|
|
||||||
# Add second book's details (right column) if it exists
|
|
||||||
if i + 1 < len(page_books):
|
|
||||||
self._add_book_details(details_row, page_books[i + 1])
|
|
||||||
else:
|
|
||||||
# Add empty cell if odd number of books
|
|
||||||
details_row.create_cell()
|
|
||||||
|
|
||||||
self.library_table = table
|
|
||||||
return table
|
|
||||||
|
|
||||||
def _add_book_cover(self, row, book: Dict):
|
|
||||||
"""
|
|
||||||
Add a book cover to a table row.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
row: Table row to add cover to
|
|
||||||
book: Book dictionary with metadata
|
|
||||||
"""
|
|
||||||
cover_cell = row.create_cell()
|
|
||||||
|
|
||||||
cover_path = book.get('cover_path')
|
|
||||||
book_path = book['path']
|
|
||||||
|
|
||||||
# Create callback that returns book path
|
|
||||||
callback = lambda point, path=book_path: path
|
|
||||||
|
|
||||||
# Add cover image
|
|
||||||
if cover_path and Path(cover_path).exists():
|
|
||||||
# Use cached cover with callback
|
|
||||||
img = InteractiveImage.create_and_add_to(
|
|
||||||
cover_cell,
|
|
||||||
source=cover_path,
|
|
||||||
alt_text=book['title'],
|
|
||||||
callback=callback
|
|
||||||
)
|
|
||||||
elif book.get('cover_data'):
|
|
||||||
# Decode base64 and save to temp file for InteractiveImage
|
|
||||||
try:
|
|
||||||
img_data = base64.b64decode(book['cover_data'])
|
|
||||||
img = Image.open(BytesIO(img_data))
|
|
||||||
|
|
||||||
# Save to temp file
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
|
||||||
img.save(tmp.name, 'PNG')
|
|
||||||
temp_path = tmp.name
|
|
||||||
self.temp_cover_files.append(temp_path)
|
|
||||||
|
|
||||||
|
if cover_path and Path(cover_path).exists():
|
||||||
|
# Use cached cover with callback
|
||||||
img = InteractiveImage.create_and_add_to(
|
img = InteractiveImage.create_and_add_to(
|
||||||
cover_cell,
|
cover_cell,
|
||||||
source=temp_path,
|
source=cover_path,
|
||||||
alt_text=book['title'],
|
alt_text=book['title'],
|
||||||
callback=callback
|
callback=callback
|
||||||
)
|
)
|
||||||
except Exception as e:
|
elif book.get('cover_data'):
|
||||||
print(f"Error creating cover image for {book['title']}: {e}")
|
# 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)
|
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):
|
# Book info cell
|
||||||
"""
|
info_cell = row.create_cell()
|
||||||
Add book details (title, author, filename) to a table row.
|
|
||||||
|
|
||||||
Args:
|
# Title paragraph
|
||||||
row: Table row to add details to
|
title_para = info_cell.create_paragraph()
|
||||||
book: Book dictionary with metadata
|
for word in book['title'].split():
|
||||||
"""
|
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
||||||
details_cell = row.create_cell()
|
|
||||||
|
|
||||||
# Title paragraph
|
# Author paragraph
|
||||||
title_para = details_cell.create_paragraph()
|
author_para = info_cell.create_paragraph()
|
||||||
for word in book['title'].split():
|
for word in book.get('author', 'Unknown').split():
|
||||||
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
author_para.add_word(Word(word, Font(font_size=12)))
|
||||||
|
|
||||||
# Author paragraph
|
# Filename paragraph (small, gray)
|
||||||
author_para = details_cell.create_paragraph()
|
filename_para = info_cell.create_paragraph()
|
||||||
for word in book.get('author', 'Unknown').split():
|
filename_para.add_word(Word(
|
||||||
author_para.add_word(Word(word, Font(font_size=12)))
|
Path(book['path']).name,
|
||||||
|
Font(font_size=10, colour=(150, 150, 150))
|
||||||
|
))
|
||||||
|
|
||||||
# Filename paragraph (small, gray)
|
self.library_table = table
|
||||||
filename_para = details_cell.create_paragraph()
|
return table
|
||||||
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"""
|
||||||
@@ -329,20 +255,15 @@ 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),
|
||||||
@@ -352,8 +273,6 @@ 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(
|
||||||
@@ -370,8 +289,6 @@ 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,
|
||||||
@@ -381,24 +298,18 @@ 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 with 2-column grid.
|
Handle tap event on library view.
|
||||||
|
|
||||||
The layout has alternating rows: cover rows and detail rows.
|
Checks if the tap is within any row's bounds and returns the corresponding
|
||||||
Each pair of rows (cover + detail) represents one pair of books (2 books).
|
book path. This makes the entire row interactive, not just the cover image.
|
||||||
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
|
||||||
@@ -412,11 +323,6 @@ 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())
|
||||||
|
|
||||||
@@ -439,43 +345,11 @@ 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")
|
||||||
|
|
||||||
# Each pair of books uses 2 rows (cover row + detail row)
|
# Return the corresponding book
|
||||||
# Determine which book pair this row belongs to
|
if body_row_index < len(self.books):
|
||||||
book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...)
|
book_path = self.books[body_row_index]['path']
|
||||||
is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details
|
print(f"Book selected (row {body_row_index}): {book_path}")
|
||||||
|
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
|
||||||
@@ -500,56 +374,6 @@ 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.
|
||||||
|
|||||||
@@ -1,454 +0,0 @@
|
|||||||
"""
|
|
||||||
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_author() or "Unknown Author"
|
self.author = book.get_metadata('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,9 +70,6 @@ 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,16 +9,14 @@ 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, font family, and rendering settings.
|
Manages font size, spacing, 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
|
||||||
@@ -29,7 +27,6 @@ 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):
|
||||||
@@ -41,7 +38,6 @@ 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]:
|
||||||
"""
|
"""
|
||||||
@@ -93,36 +89,6 @@ 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.
|
||||||
@@ -229,7 +195,6 @@ 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
|
||||||
@@ -237,7 +202,6 @@ 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
|
||||||
@@ -250,7 +214,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, font_family, line_spacing, etc.)
|
settings: Dictionary with settings (font_scale, line_spacing, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if settings applied successfully, False otherwise
|
True if settings applied successfully, False otherwise
|
||||||
@@ -259,19 +223,6 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
generate_navigation_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 open_navigation_overlay(
|
||||||
|
self,
|
||||||
|
chapters: List[Tuple[str, int]],
|
||||||
|
bookmarks: List[Dict],
|
||||||
|
base_page: Image.Image,
|
||||||
|
active_tab: str = "contents"
|
||||||
|
) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Open the unified navigation overlay with Contents and Bookmarks tabs.
|
||||||
|
|
||||||
|
This replaces the separate TOC and Bookmarks overlays with a single
|
||||||
|
overlay that has tabs for switching between contents and bookmarks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chapters: List of (chapter_title, chapter_index) tuples
|
||||||
|
bookmarks: List of bookmark dictionaries with 'name' and optional 'position'
|
||||||
|
base_page: Current reading page to show underneath
|
||||||
|
active_tab: Which tab to show ("contents" or "bookmarks")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Composited image with navigation overlay on top
|
||||||
|
"""
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from .application import EbookReader
|
||||||
|
|
||||||
|
# Calculate panel size (60% of screen width, 70% height)
|
||||||
|
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 navigation HTML with tabs
|
||||||
|
html = generate_navigation_overlay(
|
||||||
|
chapters=chapter_data,
|
||||||
|
bookmarks=bookmarks,
|
||||||
|
active_tab=active_tab,
|
||||||
|
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="Navigation",
|
||||||
|
author="",
|
||||||
|
document_id="navigation_overlay"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise ValueError("Failed to load navigation 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.NAVIGATION
|
||||||
|
|
||||||
|
# Store active tab for tab switching
|
||||||
|
self._active_nav_tab = active_tab
|
||||||
|
self._cached_chapters = chapters
|
||||||
|
self._cached_bookmarks = bookmarks
|
||||||
|
|
||||||
|
# Composite and return
|
||||||
|
return self.composite_overlay(base_page, overlay_panel)
|
||||||
|
|
||||||
|
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 composited image with new tab active, or None if not in navigation overlay
|
||||||
|
"""
|
||||||
|
if self.current_overlay != OverlayState.NAVIGATION:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Re-open navigation overlay with new active tab
|
||||||
|
if hasattr(self, '_cached_chapters') and hasattr(self, '_cached_bookmarks'):
|
||||||
|
return self.open_navigation_overlay(
|
||||||
|
chapters=self._cached_chapters,
|
||||||
|
bookmarks=self._cached_bookmarks,
|
||||||
|
base_page=self._cached_base_page,
|
||||||
|
active_tab=new_tab
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""
|
|
||||||
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',
|
|
||||||
]
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
})
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""
|
|
||||||
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, {})
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
# 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
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
#!/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,14 +156,16 @@ def main():
|
|||||||
print("=== Settings Overlay Demo ===")
|
print("=== Settings Overlay Demo ===")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Use Alice in Wonderland test book (has actual content)
|
# Find a test EPUB
|
||||||
epub_path = Path(__file__).parent.parent / 'tests' / 'data' / 'test.epub'
|
epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub'
|
||||||
|
epubs = list(epub_dir.glob('*.epub'))
|
||||||
|
|
||||||
if not epub_path.exists():
|
if not epubs:
|
||||||
print("Error: test.epub not found!")
|
print("Error: No test EPUB files found!")
|
||||||
print(f"Looked in: {epub_path}")
|
print(f"Looked in: {epub_dir}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
epub_path = epubs[0]
|
||||||
print(f"Using book: {epub_path.name}")
|
print(f"Using book: {epub_path.name}")
|
||||||
|
|
||||||
# Create reader
|
# Create reader
|
||||||
@@ -180,10 +182,6 @@ 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
|
||||||
@@ -217,8 +215,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._active_overlay and reader._active_overlay._overlay_reader:
|
if reader.overlay_manager._overlay_reader:
|
||||||
page = reader._active_overlay._overlay_reader.manager.get_current_page()
|
page = reader.overlay_manager._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):
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -18,7 +18,8 @@ This is useful for:
|
|||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from dreader import EbookReader, TouchEvent, GestureType
|
from dreader.application import EbookReader
|
||||||
|
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
||||||
from pyWebLayout.core.query import QueryResult
|
from pyWebLayout.core.query import QueryResult
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"_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": -2.0,
|
|
||||||
"spi_hz": 24000000,
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/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 HARDWARE_SETUP.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 ""
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
#!/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 HARDWARE_SETUP.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)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/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__).parent / '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()
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"""
|
|
||||||
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"])
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""
|
|
||||||
Minimal reproduction test for backward navigation bug.
|
|
||||||
|
|
||||||
BUG: Backward navigation cannot reach block_index=0 from block_index=1.
|
|
||||||
|
|
||||||
This is a pyWebLayout issue, not a dreader-application issue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 REPRODUCTION:
|
|
||||||
|
|
||||||
1. Start at block_index=0
|
|
||||||
2. Go forward once (to block_index=1)
|
|
||||||
3. Go backward once
|
|
||||||
4. BUG: Lands at block_index=1 instead of block_index=0
|
|
||||||
|
|
||||||
This proves backward navigation cannot reach the first block.
|
|
||||||
"""
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# THE BUG: This assertion will fail
|
|
||||||
print(f"\nEXPECTED: block_index=0")
|
|
||||||
print(f"ACTUAL: block_index={pos_final.block_index}")
|
|
||||||
|
|
||||||
if pos_final.block_index != 0:
|
|
||||||
print("\n❌ BUG CONFIRMED: Cannot navigate backward to block_index=0")
|
|
||||||
print(" This is a pyWebLayout bug in the previous_page() method.")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
pos_final.block_index,
|
|
||||||
0,
|
|
||||||
"BUG: Backward navigation from block 1 should return to block 0"
|
|
||||||
)
|
|
||||||
|
|
||||||
reader.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""
|
|
||||||
Detailed test for backward navigation issues.
|
|
||||||
|
|
||||||
This test explores the backward navigation behavior more thoroughly
|
|
||||||
to understand if the issue is:
|
|
||||||
1. Complete failure (previous_page returns None)
|
|
||||||
2. Imprecise positioning (lands on wrong block)
|
|
||||||
3. Only occurs after resume
|
|
||||||
4. Occurs during continuous navigation
|
|
||||||
"""
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/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!")
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
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,7 +7,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -40,21 +39,16 @@ 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 with pagination"""
|
"""Test that library table can be created"""
|
||||||
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 for 2-column grid layout
|
# Table should have body rows matching book count
|
||||||
# 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), expected_rows)
|
self.assertEqual(len(body_rows), len(books))
|
||||||
|
|
||||||
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"""
|
||||||
@@ -142,7 +136,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 with 2-column grid layout"""
|
"""Test that multiple taps work correctly"""
|
||||||
books = self.library.scan_library()
|
books = self.library.scan_library()
|
||||||
|
|
||||||
if len(books) < 3:
|
if len(books) < 3:
|
||||||
@@ -151,20 +145,16 @@ class TestLibraryInteraction(unittest.TestCase):
|
|||||||
self.library.create_library_table()
|
self.library.create_library_table()
|
||||||
self.library.render_library()
|
self.library.render_library()
|
||||||
|
|
||||||
# In 2-column layout:
|
# Tap first book (row 0: y=60-180)
|
||||||
# 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 (right column, first pair cover row)
|
# Tap second book (row 1: y=181-301)
|
||||||
path2 = self.library.handle_library_tap(x=500, y=100)
|
path2 = self.library.handle_library_tap(x=400, y=250)
|
||||||
self.assertEqual(path2, books[1]['path'])
|
self.assertEqual(path2, books[1]['path'])
|
||||||
|
|
||||||
# Tap third book (left column, second pair cover row)
|
# Tap third book (row 2: y=302-422)
|
||||||
path3 = self.library.handle_library_tap(x=100, y=360)
|
path3 = self.library.handle_library_tap(x=400, y=360)
|
||||||
self.assertEqual(path3, books[2]['path'])
|
self.assertEqual(path3, books[2]['path'])
|
||||||
|
|
||||||
# All should be different
|
# All should be different
|
||||||
@@ -172,79 +162,6 @@ 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()
|
||||||
|
|||||||
@@ -24,67 +24,21 @@ 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 - use a larger EPUB for spacing tests
|
# Load a test EPUB
|
||||||
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
|
||||||
epubs = list(epub_dir.glob('*.epub'))
|
if not test_epub.exists():
|
||||||
if not epubs:
|
# Try to find any EPUB in test data
|
||||||
self.skipTest("No test EPUB files available")
|
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
|
||||||
|
|
||||||
# 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'))
|
epubs = list(epub_dir.glob('*.epub'))
|
||||||
|
if epubs:
|
||||||
|
test_epub = epubs[0]
|
||||||
|
else:
|
||||||
|
self.skipTest("No test EPUB files available")
|
||||||
|
|
||||||
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"""
|
||||||
@@ -181,12 +135,9 @@ 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 from the active overlay sub-application
|
# Get overlay reader to query button positions
|
||||||
overlay_subapp = self.reader._active_overlay
|
overlay_manager = self.reader.overlay_manager
|
||||||
if not overlay_subapp:
|
overlay_reader = overlay_manager._overlay_reader
|
||||||
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")
|
||||||
@@ -311,17 +262,15 @@ class TestSettingsOverlay(unittest.TestCase):
|
|||||||
# Open overlay
|
# Open overlay
|
||||||
self.reader.open_settings_overlay()
|
self.reader.open_settings_overlay()
|
||||||
|
|
||||||
# Access refresh method through active overlay sub-application
|
# Access refresh method through overlay manager
|
||||||
overlay_subapp = self.reader._active_overlay
|
overlay_manager = self.reader.overlay_manager
|
||||||
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_subapp.refresh(
|
refreshed_image = overlay_manager.refresh_settings_overlay(
|
||||||
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,14 +44,9 @@ 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 sub-applications are properly initialized"""
|
"""Test that overlay manager is properly initialized"""
|
||||||
# Check that overlay sub-applications exist
|
self.assertIsNotNone(self.reader.overlay_manager)
|
||||||
self.assertIsNotNone(self.reader._overlay_subapps)
|
self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200))
|
||||||
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)
|
||||||
|
|
||||||
@@ -99,14 +94,14 @@ class TestTOCOverlay(unittest.TestCase):
|
|||||||
# Handle gesture
|
# Handle gesture
|
||||||
response = self.reader.handle_touch(event)
|
response = self.reader.handle_touch(event)
|
||||||
|
|
||||||
# Should open overlay (navigation or toc, depending on implementation)
|
# Should open overlay
|
||||||
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
||||||
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
|
self.assertEqual(response.data['overlay_type'], 'toc')
|
||||||
self.assertTrue(self.reader.is_overlay_open())
|
self.assertTrue(self.reader.is_overlay_open())
|
||||||
|
|
||||||
def test_swipe_up_from_middle_opens_navigation(self):
|
def test_swipe_up_from_middle_does_not_open_toc(self):
|
||||||
"""Test that swipe up from anywhere opens navigation overlay"""
|
"""Test that swipe up from middle of screen does NOT open TOC"""
|
||||||
# Create swipe up event from middle of screen
|
# Create swipe up event from middle of screen (y=600, which is < 80% of 1200)
|
||||||
event = TouchEvent(
|
event = TouchEvent(
|
||||||
gesture=GestureType.SWIPE_UP,
|
gesture=GestureType.SWIPE_UP,
|
||||||
x=400,
|
x=400,
|
||||||
@@ -116,10 +111,9 @@ class TestTOCOverlay(unittest.TestCase):
|
|||||||
# Handle gesture
|
# Handle gesture
|
||||||
response = self.reader.handle_touch(event)
|
response = self.reader.handle_touch(event)
|
||||||
|
|
||||||
# Should open navigation overlay from anywhere
|
# Should not open overlay
|
||||||
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
|
self.assertEqual(response.action, ActionType.NONE)
|
||||||
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
|
self.assertFalse(self.reader.is_overlay_open())
|
||||||
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"""
|
||||||
@@ -303,153 +297,13 @@ class TestOverlayRendering(unittest.TestCase):
|
|||||||
self.assertIsNotNone(html)
|
self.assertIsNotNone(html)
|
||||||
self.assertIn("Table of Contents", html)
|
self.assertIn("Table of Contents", html)
|
||||||
|
|
||||||
# Open the TOC overlay which internally renders HTML to image
|
# Render HTML to image using overlay manager
|
||||||
overlay_image = self.reader.open_toc_overlay()
|
overlay_manager = self.reader.overlay_manager
|
||||||
|
image = overlay_manager.render_html_to_image(html)
|
||||||
|
|
||||||
# Should produce valid image
|
# Should produce valid image
|
||||||
self.assertIsNotNone(overlay_image)
|
self.assertIsNotNone(image)
|
||||||
self.assertEqual(overlay_image.size, (800, 1200))
|
self.assertEqual(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__':
|
||||||
|
|||||||