Compare commits

..
18 Commits
Author SHA1 Message Date
dtourolle 18be4306bf trests for rebooting
Python CI / test (push) Failing after 47s
2025-11-08 23:56:32 +01:00
dtourolle 7518bcf835 refactor all navigation to one screen. 2025-11-08 23:50:49 +01:00
dtourolle 5d3e7fae7b Fix for CI
Python CI / test (push) Failing after 4m20s
2025-11-08 20:12:20 +01:00
dtourolle b5ae08c2b5 undo mis-commit
Python CI / test (push) Has been cancelled
2025-11-08 20:11:44 +01:00
dtourolle cda1ec3363 demo of applciation in action
Python CI / test (push) Has been cancelled
2025-11-08 20:07:49 +01:00
dtourolle fe140ba91f refactor applications to delegate responsibilites
Python CI / test (push) Failing after 4m11s
2025-11-08 19:46:49 +01:00
dtourolle 4811367905 persiitent styles
Python CI / test (push) Successful in 4m9s
2025-11-08 19:31:05 +01:00
dtourolle 11dc30ba8d clean up
Python CI / test (push) Successful in 4m7s
2025-11-08 19:04:22 +01:00
dtourolle 531d02735c improved demo 2025-11-08 18:53:26 +01:00
dtourolle 1993b0bf48 Updated demo and fixed bug in spacing
Python CI / test (push) Successful in 4m10s
2025-11-08 18:51:33 +01:00
dtourolle 284a6e3393 library and toc navigation
Python CI / test (push) Successful in 4m30s
2025-11-08 12:20:23 +01:00
dtourolle 60426432a0 add gestures from pyweblayout 2025-11-08 10:54:50 +01:00
dtourolle 06c4a8504b add test library
Python CI / test (push) Successful in 38s
2025-11-08 10:54:28 +01:00
dtourolle 131d39080e first application elements
Python CI / test (push) Successful in 3m24s
2025-11-07 20:00:05 +01:00
dtourolle c4b4b0a298 update gifs
Python CI / test (push) Successful in 3m24s
2025-11-07 19:02:58 +01:00
dtourolle 68b3570040 add test data
Python CI / test (push) Has been cancelled
2025-11-07 19:01:46 +01:00
dtourolle 9862c3124a migration of application to own repo
Python CI / test (push) Successful in 38s
2025-11-07 18:47:31 +01:00
dtourolle 2e926ab87a Migration of application to own repo 2025-11-07 18:47:10 +01:00
76 changed files with 1808 additions and 11646 deletions
-2
View File
@@ -1,2 +0,0 @@
# Mark EPUB files as binary to prevent any text transformations
*.epub binary
+2 -8
View File
@@ -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: |
-3
View File
@@ -1,3 +0,0 @@
[submodule "external/dreader-hal"]
path = external/dreader-hal
url = https://gitea.tourolle.paris/dtourolle/dreader-hal
-410
View File
@@ -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.
-552
View File
@@ -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
-286
View File
@@ -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
File diff suppressed because it is too large Load Diff
-230
View File
@@ -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
-472
View 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)
+210
View File
@@ -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)
+18 -192
View File
@@ -8,9 +8,11 @@
| ![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage-docs.svg) | **Documentation Coverage** - Percentage of code with docstrings | | ![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage-docs.svg) | **Documentation Coverage** - Percentage of code with docstrings |
| ![License](https://img.shields.io/badge/license-MIT-blue.svg) | **License** - Project licensing information | | ![License](https://img.shields.io/badge/license-MIT-blue.svg) | **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!
-196
View File
@@ -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
-58
View File
@@ -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
View File
@@ -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

-207
View File
@@ -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)
-88
View File
@@ -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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 543 KiB

+3 -10
View File
@@ -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",
] ]
+242 -90
View File
@@ -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
-32
View File
@@ -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
-4
View File
@@ -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:
-313
View File
@@ -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
-188
View File
@@ -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
-675
View File
@@ -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
-406
View File
@@ -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()
+24 -25
View File
@@ -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, {})
+38 -164
View File
@@ -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) -->
+68 -244
View File
@@ -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.
-454
View File
@@ -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
+1 -4
View File
@@ -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",
+2 -51
View File
@@ -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:
+563
View File
@@ -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
}
-21
View File
@@ -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',
]
-359
View File
@@ -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
-311
View File
@@ -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)
-251
View File
@@ -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
})
-128
View File
@@ -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, {})
+164
View File
@@ -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
+421
View File
@@ -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
-363
View File
@@ -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)
-212
View File
@@ -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)
-118
View File
@@ -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)
-173
View File
@@ -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()
+9 -11
View File
@@ -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):
-292
View File
@@ -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)
-293
View File
@@ -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)
-96
View File
@@ -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()
+2 -1
View File
@@ -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
Submodule external/dreader-hal deleted from bfa51c1439
-66
View File
@@ -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"
}
}
-80
View File
@@ -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 ""
-188
View File
@@ -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()
-445
View File
@@ -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)
-50
View File
@@ -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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-284
View File
@@ -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"])
-85
View File
@@ -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()
-239
View File
@@ -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()
-230
View File
@@ -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()
-132
View File
@@ -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!")
-228
View File
@@ -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()
+9 -92
View File
@@ -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()
+16 -67
View File
@@ -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,
+16 -162
View File
@@ -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__':