Test appplication for offdevice testing
This commit is contained in:
parent
678e1acf29
commit
01e79dfa4b
552
ARCHITECTURE.md
Normal file
552
ARCHITECTURE.md
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
# DReader Application Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
DReader is a full-featured ebook reader application built on top of [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It provides a complete reading experience with navigation, bookmarks, highlights, and customizable display settings.
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
### High-Level Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dreader/
|
||||||
|
├── application.py # Main EbookReader class (coordinator)
|
||||||
|
├── managers/ # Specialized management modules
|
||||||
|
│ ├── document.py # Document loading (EPUB/HTML)
|
||||||
|
│ ├── settings.py # Font and spacing controls
|
||||||
|
│ └── highlight_coordinator.py # Text highlighting
|
||||||
|
├── handlers/
|
||||||
|
│ └── gestures.py # Touch event routing
|
||||||
|
├── overlays/ # UI overlay system
|
||||||
|
│ ├── base.py # Base overlay functionality
|
||||||
|
│ ├── navigation.py # TOC and bookmarks overlay
|
||||||
|
│ └── settings.py # Settings overlay
|
||||||
|
├── library.py # Library browsing and book selection
|
||||||
|
├── state.py # Application state persistence
|
||||||
|
├── html_generator.py # HTML generation for overlays
|
||||||
|
└── gesture.py # Gesture definitions and responses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relationship to pyWebLayout
|
||||||
|
|
||||||
|
**pyWebLayout** provides low-level rendering primitives:
|
||||||
|
- Text layout and rendering algorithms
|
||||||
|
- Document structure and pagination
|
||||||
|
- Query systems for interactive content
|
||||||
|
- Core rendering infrastructure
|
||||||
|
|
||||||
|
**DReader** is an application framework that:
|
||||||
|
- Combines pyWebLayout components into a complete reader
|
||||||
|
- Provides high-level APIs for common ereader tasks
|
||||||
|
- Manages application state (bookmarks, highlights, positions)
|
||||||
|
- Handles business logic for gestures and interactions
|
||||||
|
|
||||||
|
Think of it as:
|
||||||
|
- **pyWebLayout** = React (library)
|
||||||
|
- **DReader** = Next.js (framework)
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### 1. EbookReader (Main Coordinator)
|
||||||
|
|
||||||
|
**Location**: [application.py](dreader/application.py)
|
||||||
|
|
||||||
|
The central orchestrator that coordinates all subsystems:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EbookReader:
|
||||||
|
"""Main ebook reader application"""
|
||||||
|
|
||||||
|
# Core dependencies
|
||||||
|
manager: EreaderLayoutManager # pyWebLayout layout engine
|
||||||
|
doc_manager: DocumentManager # Document loading
|
||||||
|
settings_manager: SettingsManager # Display settings
|
||||||
|
highlight_coordinator: HighlightCoordinator # Text highlighting
|
||||||
|
gesture_router: GestureRouter # Gesture handling
|
||||||
|
overlay_manager: OverlayManager # Overlay rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Responsibilities**:
|
||||||
|
- Document lifecycle (load, close)
|
||||||
|
- Page navigation (next, previous, chapters)
|
||||||
|
- Bookmark management
|
||||||
|
- Position persistence
|
||||||
|
- Settings coordination
|
||||||
|
- Gesture event routing
|
||||||
|
|
||||||
|
### 2. Document Manager
|
||||||
|
|
||||||
|
**Location**: [managers/document.py](dreader/managers/document.py)
|
||||||
|
|
||||||
|
Handles document loading and metadata extraction.
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Load EPUB files via pyWebLayout
|
||||||
|
- Extract book metadata (title, author, etc.)
|
||||||
|
- Provide document info to other components
|
||||||
|
|
||||||
|
### 3. Settings Manager
|
||||||
|
|
||||||
|
**Location**: [managers/settings.py](dreader/managers/settings.py)
|
||||||
|
|
||||||
|
Manages all display settings with persistence.
|
||||||
|
|
||||||
|
**Settings**:
|
||||||
|
- Font scale (adjustable font size)
|
||||||
|
- Line spacing
|
||||||
|
- Inter-block spacing (paragraph spacing)
|
||||||
|
- Word spacing
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Real-time preview in settings overlay
|
||||||
|
- Persistent across sessions
|
||||||
|
- Position preservation when settings change
|
||||||
|
|
||||||
|
### 4. Gesture Router
|
||||||
|
|
||||||
|
**Location**: [handlers/gestures.py](dreader/handlers/gestures.py)
|
||||||
|
|
||||||
|
Routes touch events to appropriate handlers based on application state.
|
||||||
|
|
||||||
|
**Gesture Types**:
|
||||||
|
- `TAP` - Word selection, link following, overlay interaction
|
||||||
|
- `SWIPE_LEFT` - Next page
|
||||||
|
- `SWIPE_RIGHT` - Previous page
|
||||||
|
- `SWIPE_UP` - Open navigation overlay (from bottom 20%)
|
||||||
|
- `SWIPE_DOWN` - Open settings overlay (from top) or close overlay
|
||||||
|
- `PINCH_IN/OUT` - Font size adjustment
|
||||||
|
- `DRAG` - Text selection (start, move, end)
|
||||||
|
|
||||||
|
**Routing Logic**:
|
||||||
|
```
|
||||||
|
Touch Event → GestureRouter
|
||||||
|
├─ Is overlay open?
|
||||||
|
│ ├─ Yes → Route to overlay handler
|
||||||
|
│ └─ No → Route to reading mode handler
|
||||||
|
└─ Return GestureResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Overlay System
|
||||||
|
|
||||||
|
**Location**: [overlays/](dreader/overlays/)
|
||||||
|
|
||||||
|
The overlay system provides modal UI panels over the reading content.
|
||||||
|
|
||||||
|
#### Overlay Manager
|
||||||
|
|
||||||
|
**Location**: [overlays/base.py](dreader/overlays/base.py)
|
||||||
|
|
||||||
|
Core overlay rendering and compositing infrastructure.
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Render overlay HTML to images
|
||||||
|
- Composite overlays over base page
|
||||||
|
- Darken background for modal effect
|
||||||
|
- Handle coordinate translation for interaction
|
||||||
|
- Cache for performance
|
||||||
|
|
||||||
|
#### Navigation Overlay
|
||||||
|
|
||||||
|
**Location**: [overlays/navigation.py](dreader/overlays/navigation.py)
|
||||||
|
|
||||||
|
Unified overlay with tabbed interface for:
|
||||||
|
- **Contents Tab**: Chapter navigation (TOC)
|
||||||
|
- **Bookmarks Tab**: Saved position management
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Tab switching without closing overlay
|
||||||
|
- Chapter selection with jump
|
||||||
|
- Bookmark selection with jump
|
||||||
|
- Add/delete bookmarks
|
||||||
|
|
||||||
|
#### Settings Overlay
|
||||||
|
|
||||||
|
**Location**: [overlays/settings.py](dreader/overlays/settings.py)
|
||||||
|
|
||||||
|
Interactive settings panel with real-time preview.
|
||||||
|
|
||||||
|
**Controls**:
|
||||||
|
- Font size: A- / A+ buttons
|
||||||
|
- Line spacing: +/- buttons
|
||||||
|
- Block spacing: +/- buttons
|
||||||
|
- Word spacing: +/- buttons
|
||||||
|
|
||||||
|
**Interaction**: Changes apply immediately, overlay refreshes to show updated values.
|
||||||
|
|
||||||
|
### 6. Library Manager
|
||||||
|
|
||||||
|
**Location**: [library.py](dreader/library.py)
|
||||||
|
|
||||||
|
Manages the library browsing experience.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Scan directory for EPUB files
|
||||||
|
- Extract and cache metadata
|
||||||
|
- Render library grid view
|
||||||
|
- Handle book selection via tap
|
||||||
|
- Cache cover images for performance
|
||||||
|
|
||||||
|
**Display**: Renders books in a grid with cover thumbnails and metadata.
|
||||||
|
|
||||||
|
### 7. State Manager
|
||||||
|
|
||||||
|
**Location**: [state.py](dreader/state.py)
|
||||||
|
|
||||||
|
Persistent application state across sessions.
|
||||||
|
|
||||||
|
**State Structure**:
|
||||||
|
```python
|
||||||
|
class AppState:
|
||||||
|
mode: EreaderMode # LIBRARY or READING
|
||||||
|
overlay: OverlayState # Current overlay type
|
||||||
|
current_book: BookState # Currently open book
|
||||||
|
library: LibraryState # Library scan cache
|
||||||
|
settings: SettingsState # Display settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Persistence**:
|
||||||
|
- Location: `~/.config/dreader/state.json`
|
||||||
|
- Auto-save every 60 seconds
|
||||||
|
- Immediate save on mode change, settings change, shutdown
|
||||||
|
- Atomic writes for safety
|
||||||
|
|
||||||
|
**Boot Behavior**:
|
||||||
|
- Resume last book at last position
|
||||||
|
- Restore all settings
|
||||||
|
- Fall back to library if book missing
|
||||||
|
|
||||||
|
## Data Flow Diagrams
|
||||||
|
|
||||||
|
### Opening an Overlay
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
↓
|
||||||
|
EbookReader.open_navigation_overlay()
|
||||||
|
├─ Get current page (base layer)
|
||||||
|
├─ Get chapters and bookmarks
|
||||||
|
↓
|
||||||
|
OverlayManager.open_navigation_overlay()
|
||||||
|
├─ Generate HTML
|
||||||
|
├─ Render to image (using temp reader)
|
||||||
|
├─ Composite over base page
|
||||||
|
│ ├─ Darken background
|
||||||
|
│ ├─ Add border
|
||||||
|
│ └─ Paste panel at center
|
||||||
|
└─ Cache base page, overlay, offset
|
||||||
|
↓
|
||||||
|
Return composited image
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay Interaction
|
||||||
|
|
||||||
|
```
|
||||||
|
User Touch (x, y)
|
||||||
|
↓
|
||||||
|
GestureRouter.handle_touch()
|
||||||
|
├─ Overlay open? YES
|
||||||
|
↓
|
||||||
|
EbookReader._handle_overlay_tap(x, y)
|
||||||
|
↓
|
||||||
|
OverlayManager.query_overlay_pixel(x, y)
|
||||||
|
├─ Translate screen coords to overlay coords
|
||||||
|
├─ Query pyWebLayout for link at position
|
||||||
|
└─ Return link_target (e.g., "chapter:5")
|
||||||
|
↓
|
||||||
|
Parse link_target and execute action:
|
||||||
|
├─ "chapter:N" → jump_to_chapter(N), close overlay
|
||||||
|
├─ "bookmark:name" → load_position(name), close overlay
|
||||||
|
├─ "setting:action" → apply setting, refresh overlay
|
||||||
|
└─ "tab:name" → switch tab, keep overlay open
|
||||||
|
↓
|
||||||
|
Return GestureResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Persistence
|
||||||
|
|
||||||
|
```
|
||||||
|
Application Running
|
||||||
|
↓
|
||||||
|
StateManager auto-save timer (every 60s)
|
||||||
|
├─ Gather current state
|
||||||
|
├─ Serialize to JSON
|
||||||
|
└─ Atomic write to disk
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
User performs action (page turn, setting change)
|
||||||
|
├─ StateManager.save_state()
|
||||||
|
└─ Immediate write
|
||||||
|
|
||||||
|
Application Shutdown
|
||||||
|
├─ Save position: reader.save_position("__auto_resume__")
|
||||||
|
├─ Stop auto-save
|
||||||
|
└─ Final state.json write
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boot Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
Application Start
|
||||||
|
↓
|
||||||
|
StateManager.load_state()
|
||||||
|
├─ Read state.json
|
||||||
|
├─ Validate and parse
|
||||||
|
└─ Create AppState object
|
||||||
|
↓
|
||||||
|
Check previous mode:
|
||||||
|
├─ READING mode?
|
||||||
|
│ ├─ Load last book
|
||||||
|
│ ├─ Apply saved settings
|
||||||
|
│ └─ Restore position ("__auto_resume__")
|
||||||
|
│
|
||||||
|
└─ LIBRARY mode?
|
||||||
|
└─ Show library grid
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
### Application State Files
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/dreader/
|
||||||
|
├── state.json # Application state
|
||||||
|
├── covers/ # Cached book covers
|
||||||
|
│ └── {book_id}.png
|
||||||
|
├── bookmarks/ # Per-book bookmarks
|
||||||
|
│ └── {document_id}_{bookmark_name}.json
|
||||||
|
└── highlights/ # Per-book highlights
|
||||||
|
└── {document_id}_highlights.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bookmark Format
|
||||||
|
|
||||||
|
Each book's position is stored separately using document ID:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document_id": "book123",
|
||||||
|
"bookmark_name": "__auto_resume__",
|
||||||
|
"position": {
|
||||||
|
"offset": 1234,
|
||||||
|
"chapter": 5
|
||||||
|
},
|
||||||
|
"timestamp": "2025-11-09T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesture Handling
|
||||||
|
|
||||||
|
### Gesture Priority and Routing
|
||||||
|
|
||||||
|
```
|
||||||
|
Touch Event
|
||||||
|
↓
|
||||||
|
Is overlay open?
|
||||||
|
├─ YES → Overlay Mode
|
||||||
|
│ ├─ TAP → Handle overlay interaction
|
||||||
|
│ ├─ SWIPE_DOWN → Close overlay
|
||||||
|
│ └─ Other → Ignore (modal behavior)
|
||||||
|
│
|
||||||
|
└─ NO → Reading Mode
|
||||||
|
├─ TAP
|
||||||
|
│ ├─ On link → Follow link
|
||||||
|
│ ├─ On word → Select word
|
||||||
|
│ ├─ Left edge → Previous page
|
||||||
|
│ └─ Right edge → Next page
|
||||||
|
│
|
||||||
|
├─ SWIPE
|
||||||
|
│ ├─ LEFT → Next page
|
||||||
|
│ ├─ RIGHT → Previous page
|
||||||
|
│ ├─ UP (from bottom 20%) → Open navigation
|
||||||
|
│ └─ DOWN (from top 20%) → Open settings
|
||||||
|
│
|
||||||
|
├─ PINCH
|
||||||
|
│ ├─ IN → Decrease font size
|
||||||
|
│ └─ OUT → Increase font size
|
||||||
|
│
|
||||||
|
└─ DRAG
|
||||||
|
├─ START → Begin text selection
|
||||||
|
├─ MOVE → Extend selection
|
||||||
|
└─ END → Complete selection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Types
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ActionType(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
PAGE_TURN = "page_turn"
|
||||||
|
WORD_SELECTED = "word_selected"
|
||||||
|
LINK_FOLLOWED = "link_followed"
|
||||||
|
CHAPTER_SELECTED = "chapter_selected"
|
||||||
|
BOOKMARK_SELECTED = "bookmark_selected"
|
||||||
|
SETTING_CHANGED = "setting_changed"
|
||||||
|
OVERLAY_OPENED = "overlay_opened"
|
||||||
|
OVERLAY_CLOSED = "overlay_closed"
|
||||||
|
TAB_SWITCHED = "tab_switched"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
### Rendering Performance
|
||||||
|
|
||||||
|
- **Page Turn**: ~50-100ms (depends on page complexity)
|
||||||
|
- **Overlay Open**: ~200-250ms (includes HTML generation and rendering)
|
||||||
|
- **Tab Switch**: ~125ms (uses cached base page)
|
||||||
|
- **Setting Change**: ~150ms (re-render with new settings)
|
||||||
|
- **Tap Interaction**: ~5-10ms (coordinate query)
|
||||||
|
|
||||||
|
### Memory Usage
|
||||||
|
|
||||||
|
- **Base Application**: ~20-30MB
|
||||||
|
- **Per Book**: ~10-50MB (depends on images)
|
||||||
|
- **Overlay Cache**: ~5-10MB
|
||||||
|
|
||||||
|
### Optimization Strategies
|
||||||
|
|
||||||
|
1. **Caching**:
|
||||||
|
- Base page cached during overlay display
|
||||||
|
- Overlay panel cached for tab switching
|
||||||
|
- Cover images cached to disk
|
||||||
|
- Metadata cached between sessions
|
||||||
|
|
||||||
|
2. **Lazy Loading**:
|
||||||
|
- Library covers loaded on-demand
|
||||||
|
- Book content loaded only when opened
|
||||||
|
- Overlays rendered only when needed
|
||||||
|
|
||||||
|
3. **Efficient Updates**:
|
||||||
|
- Tab switching reuses base page
|
||||||
|
- Setting changes use incremental rendering
|
||||||
|
- Position saves are debounced
|
||||||
|
|
||||||
|
## Extension Points
|
||||||
|
|
||||||
|
### Adding New Overlays
|
||||||
|
|
||||||
|
To add a new overlay type:
|
||||||
|
|
||||||
|
1. Define new `OverlayState` enum value in [state.py](dreader/state.py#L27-L33)
|
||||||
|
2. Create HTML generator in [html_generator.py](dreader/html_generator.py)
|
||||||
|
3. Add overlay class in `overlays/` directory
|
||||||
|
4. Implement open/close methods in [overlay manager](dreader/overlays/base.py)
|
||||||
|
5. Add gesture handling in [application.py](dreader/application.py)
|
||||||
|
|
||||||
|
### Custom Gesture Handlers
|
||||||
|
|
||||||
|
To add custom gestures:
|
||||||
|
|
||||||
|
1. Define gesture type in [gesture.py](dreader/gesture.py)
|
||||||
|
2. Add handler in [gestures.py](dreader/handlers/gestures.py)
|
||||||
|
3. Define action type for response
|
||||||
|
4. Update gesture router logic
|
||||||
|
|
||||||
|
### HAL Integration
|
||||||
|
|
||||||
|
To integrate with hardware:
|
||||||
|
|
||||||
|
Create a display abstraction layer implementing:
|
||||||
|
```python
|
||||||
|
class DisplayHAL(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def show_image(self, image: Image.Image):
|
||||||
|
"""Display image on hardware"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_touch_events(self) -> Iterator[TouchEvent]:
|
||||||
|
"""Get touch input from hardware"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_brightness(self, level: int):
|
||||||
|
"""Control display brightness"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- **E-ink**: IT8951, Remarkable device SDK
|
||||||
|
- **Desktop**: pygame, tkinter
|
||||||
|
- **Web**: Flask + HTML canvas
|
||||||
|
- **Qt**: QPixmap + QTouchEvent
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- State serialization and persistence
|
||||||
|
- Gesture routing logic
|
||||||
|
- Coordinate translation
|
||||||
|
- HTML generation
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- Mode transitions (LIBRARY ↔ READING)
|
||||||
|
- Overlay lifecycle (open → interact → close)
|
||||||
|
- Boot recovery and resume
|
||||||
|
- Settings persistence
|
||||||
|
|
||||||
|
### Example-Based Testing
|
||||||
|
|
||||||
|
Working examples demonstrate full integration:
|
||||||
|
- [simple_ereader_example.py](examples/simple_ereader_example.py)
|
||||||
|
- [library_reading_integration.py](examples/library_reading_integration.py)
|
||||||
|
- [navigation_overlay_example.py](examples/navigation_overlay_example.py)
|
||||||
|
- [demo_settings_overlay.py](examples/demo_settings_overlay.py)
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
### Component-Based Architecture
|
||||||
|
|
||||||
|
- **Managers**: Single-responsibility modules for specific tasks
|
||||||
|
- **Handlers**: Event routing and processing
|
||||||
|
- **Overlays**: Self-contained UI components
|
||||||
|
|
||||||
|
### Delegation Over Inheritance
|
||||||
|
|
||||||
|
- EbookReader delegates to specialized managers
|
||||||
|
- No deep inheritance hierarchies
|
||||||
|
- Composition for flexibility
|
||||||
|
|
||||||
|
### State Machine Pattern
|
||||||
|
|
||||||
|
- Clear state transitions (modes, overlays)
|
||||||
|
- State persistence for resume
|
||||||
|
- Predictable behavior
|
||||||
|
|
||||||
|
### Event-Driven Architecture
|
||||||
|
|
||||||
|
- Touch events drive all interactions
|
||||||
|
- Response objects communicate results
|
||||||
|
- Decoupled components
|
||||||
|
|
||||||
|
## Future Architecture Considerations
|
||||||
|
|
||||||
|
### Sub-Application Pattern
|
||||||
|
|
||||||
|
Current overlay handling uses a monolithic approach. Future refactoring could extract overlays into sub-applications:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OverlaySubApplication(ABC):
|
||||||
|
def open(self, context: OverlayContext) -> Image.Image: ...
|
||||||
|
def handle_tap(self, x: int, y: int) -> GestureResponse: ...
|
||||||
|
def close(self) -> Image.Image: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Self-contained overlay logic
|
||||||
|
- Easier testing
|
||||||
|
- Plugin support
|
||||||
|
- Composable overlays
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Enable third-party extensions:
|
||||||
|
- Custom overlay types
|
||||||
|
- Additional gestures
|
||||||
|
- Export formats
|
||||||
|
- Cloud sync providers
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [pyWebLayout Documentation](https://gitea.tourolle.paris/dtourolle/pyWebLayout)
|
||||||
|
- [REQUIREMENTS.md](REQUIREMENTS.md) - Detailed feature specifications
|
||||||
|
- [README.md](README.md) - User-facing documentation
|
||||||
|
- [examples/](examples/) - Working code examples
|
||||||
@ -1,210 +0,0 @@
|
|||||||
# HTML Generation for dreader
|
|
||||||
|
|
||||||
This document describes how to use the HTML generation features in dreader to create UI for e-reader applications.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The dreader library now includes HTML generation capabilities that allow you to create complete user interfaces programmatically. This is designed to work with a Hardware Abstraction Layer (HAL) that handles the actual display rendering and input processing.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ dreader Library │
|
|
||||||
│ ├─ EbookReader (book rendering) │
|
|
||||||
│ ├─ html_generator (UI generation) │
|
|
||||||
│ └─ book_utils (scanning/metadata) │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
↓ HTML strings
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ HAL (Hardware Abstraction Layer) │
|
|
||||||
│ - Receives HTML strings │
|
|
||||||
│ - Renders to display │
|
|
||||||
│ - Captures touch/button input │
|
|
||||||
│ - Calls back to dreader │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Page and Overlay Concept
|
|
||||||
|
|
||||||
The UI uses a **page/overlay** architecture:
|
|
||||||
|
|
||||||
- **Page (background)**: The main book content rendered as an image
|
|
||||||
- **Overlay (foreground)**: UI elements like settings, table of contents, bookmarks, etc.
|
|
||||||
|
|
||||||
## Available Modules
|
|
||||||
|
|
||||||
### 1. html_generator
|
|
||||||
|
|
||||||
Functions for generating HTML strings:
|
|
||||||
|
|
||||||
- `generate_library_html(books)` - Grid view of all books with covers
|
|
||||||
- `generate_reader_html(title, author, page_data)` - Book reading view
|
|
||||||
- `generate_settings_overlay()` - Settings panel
|
|
||||||
- `generate_toc_overlay(chapters)` - Table of contents
|
|
||||||
- `generate_bookmarks_overlay(bookmarks)` - Bookmarks list
|
|
||||||
|
|
||||||
### 2. book_utils
|
|
||||||
|
|
||||||
Utilities for managing books:
|
|
||||||
|
|
||||||
- `scan_book_directory(path)` - Scan directory for EPUB files
|
|
||||||
- `extract_book_metadata(epub_path)` - Get title, author, cover
|
|
||||||
- `get_chapter_list(reader)` - Format chapters for TOC
|
|
||||||
- `get_bookmark_list(reader)` - Format bookmarks
|
|
||||||
- `page_image_to_base64(image)` - Convert page image to base64
|
|
||||||
|
|
||||||
## Usage Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
from dreader import create_ebook_reader
|
|
||||||
from dreader.html_generator import (
|
|
||||||
generate_library_html,
|
|
||||||
generate_reader_html,
|
|
||||||
generate_toc_overlay
|
|
||||||
)
|
|
||||||
from dreader.book_utils import (
|
|
||||||
scan_book_directory,
|
|
||||||
get_chapter_list,
|
|
||||||
page_image_to_base64
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. Show library view
|
|
||||||
books_dir = Path('books')
|
|
||||||
books = scan_book_directory(books_dir)
|
|
||||||
library_html = generate_library_html(books)
|
|
||||||
# Pass library_html to HAL for rendering
|
|
||||||
|
|
||||||
# 2. User selects a book
|
|
||||||
selected_book = books[0]
|
|
||||||
reader = create_ebook_reader(page_size=(800, 1000))
|
|
||||||
reader.load_epub(selected_book['path'])
|
|
||||||
|
|
||||||
# 3. Show reader view
|
|
||||||
page_image = reader.get_current_page()
|
|
||||||
page_base64 = page_image_to_base64(page_image)
|
|
||||||
reader_html = generate_reader_html(
|
|
||||||
book_title=reader.book_title,
|
|
||||||
book_author=reader.book_author,
|
|
||||||
page_image_data=page_base64
|
|
||||||
)
|
|
||||||
# Pass reader_html to HAL for rendering
|
|
||||||
|
|
||||||
# 4. User presses "Contents" button - show TOC overlay
|
|
||||||
chapters = get_chapter_list(reader)
|
|
||||||
toc_html = generate_toc_overlay(chapters)
|
|
||||||
# Pass toc_html to HAL for rendering on top of page
|
|
||||||
```
|
|
||||||
|
|
||||||
## HTML Structure
|
|
||||||
|
|
||||||
### Library View
|
|
||||||
|
|
||||||
The library uses an HTML table for grid layout:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<table class="library-grid">
|
|
||||||
<tr>
|
|
||||||
<td class="book-item">
|
|
||||||
<table>
|
|
||||||
<tr><td class="cover-cell"><img src="..."></td></tr>
|
|
||||||
<tr><td class="title-cell">Book Title</td></tr>
|
|
||||||
<tr><td class="author-cell">Author Name</td></tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
<!-- More books... -->
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reader View
|
|
||||||
|
|
||||||
The reader view has three sections:
|
|
||||||
|
|
||||||
- Header: Book info + buttons (Library, Contents, Settings)
|
|
||||||
- Page container: Centered book page image
|
|
||||||
- Footer: Navigation buttons (Previous, Next)
|
|
||||||
|
|
||||||
### Overlays
|
|
||||||
|
|
||||||
Overlays use:
|
|
||||||
|
|
||||||
- Semi-transparent background (`rgba(0, 0, 0, 0.7)`)
|
|
||||||
- Centered white panel
|
|
||||||
- Close button
|
|
||||||
- Table-based layout for content
|
|
||||||
|
|
||||||
## Button/Link Interaction
|
|
||||||
|
|
||||||
All interactive elements have:
|
|
||||||
|
|
||||||
- `id` attributes for buttons (e.g., `id="btn-next"`)
|
|
||||||
- `data-*` attributes for dynamic content (e.g., `data-chapter-index="5"`)
|
|
||||||
- CSS classes for styling (e.g., `class="nav-button"`)
|
|
||||||
|
|
||||||
Your HAL should:
|
|
||||||
|
|
||||||
1. Parse the HTML to identify interactive elements
|
|
||||||
2. Map touch/click coordinates to elements
|
|
||||||
3. Call appropriate dreader methods
|
|
||||||
4. Regenerate and render updated HTML
|
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
Run the included demo to see all features:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source venv/bin/activate
|
|
||||||
python examples/html_generation_demo.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This will generate example HTML files in the `output/` directory that you can open in a browser to preview.
|
|
||||||
|
|
||||||
## Integration with HAL
|
|
||||||
|
|
||||||
Your HAL should implement:
|
|
||||||
|
|
||||||
1. **HTML Rendering**: Parse and display HTML strings
|
|
||||||
2. **Touch Input**: Map touch coordinates to HTML elements
|
|
||||||
3. **State Management**: Maintain reader state between interactions
|
|
||||||
4. **Re-rendering**: Update display when state changes
|
|
||||||
|
|
||||||
Example HAL flow:
|
|
||||||
|
|
||||||
```
|
|
||||||
User touches screen
|
|
||||||
↓
|
|
||||||
HAL identifies touched element (e.g., "btn-next")
|
|
||||||
↓
|
|
||||||
HAL calls reader.next_page()
|
|
||||||
↓
|
|
||||||
HAL regenerates reader_html with new page
|
|
||||||
↓
|
|
||||||
HAL renders updated HTML
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
All HTML includes inline CSS for complete styling. The design is:
|
|
||||||
|
|
||||||
- Clean, minimal interface
|
|
||||||
- Dark theme for reader (reduces eye strain)
|
|
||||||
- Large touch targets for buttons
|
|
||||||
- Responsive layout using tables (widely supported)
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
To customize the UI:
|
|
||||||
|
|
||||||
1. Edit functions in `dreader/html_generator.py`
|
|
||||||
2. Modify CSS in the `<style>` blocks
|
|
||||||
3. Change layout structure in the HTML templates
|
|
||||||
4. Adjust colors, fonts, spacing as needed
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `dreader/html_generator.py` - HTML generation functions
|
|
||||||
- `dreader/book_utils.py` - Book scanning and utilities
|
|
||||||
- `examples/html_generation_demo.py` - Complete demonstration
|
|
||||||
- `output/` - Generated HTML examples (after running demo)
|
|
||||||
965
REQUIREMENTS.md
Normal file
965
REQUIREMENTS.md
Normal file
@ -0,0 +1,965 @@
|
|||||||
|
# E-Reader Application Requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document defines the requirements for a full-featured e-reader application built on the dreader/pyWebLayout framework. The application will support multiple modes, state persistence, and a complete user interaction model.
|
||||||
|
|
||||||
|
## Implementation Status Summary
|
||||||
|
|
||||||
|
### Legend
|
||||||
|
- ✅ **Complete**: Fully implemented and tested
|
||||||
|
- 📖 **Example Ready**: Working examples exist, needs production controller
|
||||||
|
- 🔜 **Planned**: Designed but not yet implemented
|
||||||
|
- ⏸️ **Future**: Deferred to later phase
|
||||||
|
|
||||||
|
### Core Features Status
|
||||||
|
| Feature | Status | Location |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| EbookReader (page rendering, navigation) | ✅ Complete | [application.py](dreader/application.py) |
|
||||||
|
| Gesture handling (tap, swipe, pinch) | ✅ Complete | [gesture.py](dreader/gesture.py) |
|
||||||
|
| Navigation Overlay (unified TOC + Bookmarks) | ✅ Complete | [application.py](dreader/application.py), [examples/navigation_overlay_example.py](examples/navigation_overlay_example.py) |
|
||||||
|
| Settings Overlay | ✅ Complete | [application.py](dreader/application.py#L1318-L1349) |
|
||||||
|
| Highlighting system | ✅ Complete | [application.py](dreader/application.py#L1086-L1288) |
|
||||||
|
| Library management | ✅ Complete | [library.py](dreader/library.py) |
|
||||||
|
| State persistence | ✅ Complete | [state.py](dreader/state.py) |
|
||||||
|
| Persistent rendering settings | ✅ Complete | [examples/persistent_settings_example.py](examples/persistent_settings_example.py) |
|
||||||
|
| Book scanning & metadata | ✅ Complete | [book_utils.py](dreader/book_utils.py) |
|
||||||
|
| Mode transitions (LIBRARY ↔ READING) | 📖 Example Ready | [examples/library_reading_integration.py](examples/library_reading_integration.py) |
|
||||||
|
| Full application integration | 📖 Example Ready | All components working, needs main controller |
|
||||||
|
| Word Lookup Overlay | 🔜 Spec complete | Phase 2 |
|
||||||
|
| X-Ray feature | 🔜 Spec complete | Phase 2 |
|
||||||
|
|
||||||
|
### Phase Completion
|
||||||
|
- **Phase 1 (MVP)**: **~95% complete** - All core features working with examples, needs production main controller
|
||||||
|
- **Phase 2**: ~60% complete - Unified navigation overlay done, settings persistence complete, word lookup planned
|
||||||
|
- **Phase 3**: 0% complete - Advanced features deferred
|
||||||
|
|
||||||
|
### Working Examples
|
||||||
|
The following complete, runnable examples demonstrate full integration:
|
||||||
|
|
||||||
|
| Example | Demonstrates | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| [simple_ereader_example.py](examples/simple_ereader_example.py) | Basic reading, page navigation, bookmarks | ✅ Working |
|
||||||
|
| [library_reading_integration.py](examples/library_reading_integration.py) | **Full LIBRARY ↔ READING workflow** | ✅ Working |
|
||||||
|
| [persistent_settings_example.py](examples/persistent_settings_example.py) | Settings persistence across sessions | ✅ Working |
|
||||||
|
| [navigation_overlay_example.py](examples/navigation_overlay_example.py) | Unified navigation with tabs | ✅ Working |
|
||||||
|
| [demo_settings_overlay.py](examples/demo_settings_overlay.py) | Settings overlay interactions | ✅ Working |
|
||||||
|
| [word_selection_highlighting.py](examples/word_selection_highlighting.py) | Text highlighting | ✅ Working |
|
||||||
|
|
||||||
|
**Key Integration Example**: [library_reading_integration.py](examples/library_reading_integration.py) demonstrates the complete user flow:
|
||||||
|
1. Display library of EPUB files
|
||||||
|
2. Select book by clicking/tapping
|
||||||
|
3. Open and read selected book
|
||||||
|
4. Access settings overlay
|
||||||
|
5. Return to library from settings
|
||||||
|
6. Select another book
|
||||||
|
7. Auto-resume from saved position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Application Modes
|
||||||
|
|
||||||
|
### 1.1 LIBRARY Mode ✅ (Implemented in library.py)
|
||||||
|
**Purpose**: Browse and select books from the user's library
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Display grid/table of available books with cover images
|
||||||
|
- Show book metadata (title, author)
|
||||||
|
- Book selection via touch/click
|
||||||
|
- Visual feedback on selection (highlight, hover state)
|
||||||
|
- Support for scrolling/pagination if library is large
|
||||||
|
- Search/filter books (future enhancement)
|
||||||
|
- Sort options (by title, author, recent, etc.) (future enhancement)
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap/Click on book**: Transition to READING mode with selected book
|
||||||
|
- **Long-press on book**: Show book context menu (delete, info, etc.) (future)
|
||||||
|
- **Swipe**: Scroll library view
|
||||||
|
|
||||||
|
**Display Requirements**:
|
||||||
|
- Cover images: 150-300px wide thumbnails
|
||||||
|
- Title/Author text below or beside cover
|
||||||
|
- Responsive layout for different screen sizes
|
||||||
|
- Loading indicator while scanning library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 READING Mode ✅ (Implemented in application.py)
|
||||||
|
**Purpose**: Read the current book with page navigation
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Display current page rendered by pyWebLayout
|
||||||
|
- Page navigation (next/previous)
|
||||||
|
- Access to overlays via buttons/gestures
|
||||||
|
- Visual progress indicator (page numbers, percentage)
|
||||||
|
- Header showing book title/author
|
||||||
|
- Footer with navigation controls
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap left/right edge**: Previous/Next page
|
||||||
|
- **Swipe left/right**: Previous/Next page
|
||||||
|
- **Tap center**: Toggle controls visibility (future)
|
||||||
|
- **Tap header buttons**: Open overlays (TOC, Settings, etc.)
|
||||||
|
- **Pinch in/out**: Decrease/Increase font size
|
||||||
|
- **Long-press on word**: Show definition/highlight menu (future)
|
||||||
|
|
||||||
|
**Sub-states within READING mode**:
|
||||||
|
1. **Base reading state**: Just the page content
|
||||||
|
2. **TOC_OVERLAY**: Table of contents over page
|
||||||
|
3. **SETTINGS_OVERLAY**: Settings panel over page
|
||||||
|
4. **BOOKMARKS_OVERLAY**: Bookmarks list over page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Overlay States
|
||||||
|
|
||||||
|
#### 1.3.1 TOC_OVERLAY ✅ (Implemented)
|
||||||
|
**Purpose**: Navigate to different chapters
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Scrollable list of chapters
|
||||||
|
- Hierarchical chapter display (if available)
|
||||||
|
- Current chapter indication
|
||||||
|
- Click to jump to chapter
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap chapter**: Jump to chapter, close overlay, return to READING
|
||||||
|
- **Tap close/back**: Return to READING mode
|
||||||
|
- **Tap outside overlay**: Close overlay (optional)
|
||||||
|
|
||||||
|
#### 1.3.2 SETTINGS_OVERLAY ✅ (Implemented)
|
||||||
|
**Purpose**: Adjust reading preferences
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Font size controls (A-, A+)
|
||||||
|
- Line spacing controls
|
||||||
|
- Brightness controls (if hardware supports)
|
||||||
|
- Theme selection (day/night/sepia) (future)
|
||||||
|
- WiFi configuration (future)
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap buttons**: Adjust settings in real-time
|
||||||
|
- **Tap close**: Return to READING mode
|
||||||
|
- Changes persist across sessions
|
||||||
|
|
||||||
|
#### 1.3.3 BOOKMARKS_OVERLAY ✅ (Implemented)
|
||||||
|
**Purpose**: Manage and navigate bookmarks
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- List of saved bookmarks with names
|
||||||
|
- Show bookmark position info (chapter, page)
|
||||||
|
- Delete bookmark option
|
||||||
|
- Add new bookmark option
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap bookmark**: Jump to bookmark, close overlay, return to READING
|
||||||
|
- **Tap delete**: Remove bookmark from list
|
||||||
|
- **Tap close**: Return to READING mode
|
||||||
|
|
||||||
|
#### 1.3.4 WORD_LOOKUP_OVERLAY 🔜 (Planned for Phase 2)
|
||||||
|
**Purpose**: Provide word definitions and contextual analysis
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- **Dictionary Lookup**: Show word definition, pronunciation, etymology
|
||||||
|
- **X-Ray Feature**: Display LLM-generated contextual information about characters, places, and concepts
|
||||||
|
- Show **spoiler-free** summary of what has been revealed about this entity up to current reading position
|
||||||
|
- Character information: relationships, motivations, key events involving them (up to current page only)
|
||||||
|
- Place information: descriptions, significance, events that occurred there (up to current page only)
|
||||||
|
- Concept information: explanations, thematic importance (up to current page only)
|
||||||
|
- Pre-generated per book via LLM analysis (offline process), stored in cache
|
||||||
|
- Never reveals information from future chapters
|
||||||
|
- **Highlight Options**: Add permanent highlight to selected word with color choice
|
||||||
|
- **Copy to Clipboard**: Copy word or selection
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- **Tap "Dictionary"**: Show dictionary definition panel
|
||||||
|
- **Tap "X-Ray"**: Show spoiler-free contextual summary (if available for this entity)
|
||||||
|
- **Tap "Highlight"**: Add colored highlight, show color picker
|
||||||
|
- **Tap "Copy"**: Copy text to clipboard
|
||||||
|
- **Tap close/outside**: Return to READING mode
|
||||||
|
|
||||||
|
**Display Layout**:
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Selected: "Sherlock Holmes" │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [Dictionary] [X-Ray] [Highlight] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ (Content area based on active tab) │
|
||||||
|
│ │
|
||||||
|
│ Dictionary Tab: │
|
||||||
|
│ Definition, pronunciation, etc. │
|
||||||
|
│ │
|
||||||
|
│ X-Ray Tab: │
|
||||||
|
│ SHERLOCK HOLMES (Character) │
|
||||||
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||||
|
│ A consulting detective living in │
|
||||||
|
│ London at 221B Baker Street. Known │
|
||||||
|
│ for exceptional deductive reasoning│
|
||||||
|
│ and observational skills. │
|
||||||
|
│ │
|
||||||
|
│ Key Relationships (so far): │
|
||||||
|
│ • Dr. Watson - Companion, narrator │
|
||||||
|
│ • Mrs. Hudson - Landlady │
|
||||||
|
│ │
|
||||||
|
│ Mentioned: 23 times (Chapters 1-3) │
|
||||||
|
│ [Info current as of your position] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Requirements**:
|
||||||
|
- X-Ray data file per book (JSON format)
|
||||||
|
- Generated via LLM chapter-by-chapter analysis (offline process)
|
||||||
|
- Structure: `{entity_name: {chapter_N: {summary, relationships, ...}}}`
|
||||||
|
- Lookup uses current reading position to filter out future content
|
||||||
|
- Dictionary API integration (local or online)
|
||||||
|
- Entity recognition (match selected word to X-Ray entities)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. State Management & Persistence ✅ (Implemented in state.py)
|
||||||
|
|
||||||
|
### 2.1 State Definition
|
||||||
|
The application must track and persist the following state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EreaderMode(Enum):
|
||||||
|
LIBRARY = "library"
|
||||||
|
READING = "reading"
|
||||||
|
|
||||||
|
class OverlayState(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
TOC = "toc"
|
||||||
|
SETTINGS = "settings"
|
||||||
|
BOOKMARKS = "bookmarks"
|
||||||
|
WORD_LOOKUP = "word_lookup"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Persistent State Data Structure
|
||||||
|
|
||||||
|
**Key Insight**: EbookReader already manages bookmarks and positions via `bookmarks_dir`. Our state only needs to track high-level application state, not page positions.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"mode": "reading",
|
||||||
|
"overlay": "none",
|
||||||
|
"current_book": {
|
||||||
|
"path": "/path/to/book.epub",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"last_read_timestamp": "2025-11-07T10:30:00Z"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"books_path": "/path/to/library",
|
||||||
|
"last_selected_index": 3,
|
||||||
|
"scan_cache": [
|
||||||
|
{
|
||||||
|
"path": "/path/to/book.epub",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author",
|
||||||
|
"cover_cached": true,
|
||||||
|
"last_modified": "2025-11-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"font_scale": 1.0,
|
||||||
|
"line_spacing": 5,
|
||||||
|
"inter_block_spacing": 15,
|
||||||
|
"brightness": 8,
|
||||||
|
"theme": "day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's NOT in state (handled by EbookReader)**:
|
||||||
|
- Current page position → `EreaderLayoutManager.current_position`
|
||||||
|
- Bookmarks → `bookmarks_dir/{document_id}_{bookmark_name}.json`
|
||||||
|
- Reading progress → Calculated on-demand by `reader.get_reading_progress()`
|
||||||
|
|
||||||
|
**Auto-Resume Strategy**:
|
||||||
|
- Use special bookmark name `"__auto_resume__"` for last position
|
||||||
|
- Bookmark files are per-book: `{document_id}___auto_resume__.json`
|
||||||
|
- On shutdown: `reader.save_position("__auto_resume__")`
|
||||||
|
- On startup: `reader.load_position("__auto_resume__")`
|
||||||
|
- No mixing between books (each has its own document_id)
|
||||||
|
|
||||||
|
### 2.3 State Persistence Requirements
|
||||||
|
- **Location**:
|
||||||
|
- Linux/Mac: `~/.config/dreader/state.json`
|
||||||
|
- Windows: `%APPDATA%/dreader/state.json`
|
||||||
|
- **Save Triggers**:
|
||||||
|
- On mode change
|
||||||
|
- On page turn (debounced to avoid excessive writes)
|
||||||
|
- On settings change
|
||||||
|
- On bookmark add/delete
|
||||||
|
- On application close
|
||||||
|
- **Load Triggers**:
|
||||||
|
- On application startup
|
||||||
|
- After crash/power loss (automatic recovery)
|
||||||
|
- **Safety**:
|
||||||
|
- Atomic writes (write to temp file, then rename)
|
||||||
|
- Validation on load (schema check, corrupt data handling)
|
||||||
|
- Backup previous state file
|
||||||
|
- Handle missing/corrupt state gracefully
|
||||||
|
|
||||||
|
### 2.4 Boot/Resume Behavior
|
||||||
|
1. **Cold Start** (no state file):
|
||||||
|
- Start in LIBRARY mode
|
||||||
|
- Default settings applied
|
||||||
|
|
||||||
|
2. **Resume from State**:
|
||||||
|
- If last mode was LIBRARY: Return to library view
|
||||||
|
- If last mode was READING: Reopen last book at last page
|
||||||
|
- Restore all settings (font size, spacing, etc.)
|
||||||
|
- Clear overlay state (always start without overlay)
|
||||||
|
|
||||||
|
3. **Error Handling**:
|
||||||
|
- If last book no longer exists: Return to LIBRARY mode
|
||||||
|
- If state file corrupt: Start fresh, backup corrupt file
|
||||||
|
- Log all state errors for debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Interaction Model ✅ (Implemented via gesture.py + application.handle_touch)
|
||||||
|
|
||||||
|
### 3.1 Touch/Click Callbacks
|
||||||
|
The application must support callbacks/handlers for:
|
||||||
|
|
||||||
|
**LIBRARY Mode**:
|
||||||
|
- `on_book_selected(book_path)` - Open book in READING mode
|
||||||
|
- `on_library_scroll(direction)` - Scroll library view
|
||||||
|
|
||||||
|
**READING Mode**:
|
||||||
|
- `on_page_turn(direction)` - Next/Previous page
|
||||||
|
- `on_tap_region(x, y, region)` - Handle tap on specific region
|
||||||
|
- Regions: left-edge, right-edge, center, header, footer
|
||||||
|
- `on_button_press(button_id)` - Handle button clicks
|
||||||
|
- Buttons: toc, settings, bookmarks, library
|
||||||
|
- `on_zoom_gesture(direction)` - Pinch in/out for font size
|
||||||
|
- `on_word_tap(x, y)` - Single tap on word for quick highlight
|
||||||
|
- `on_word_long_press(x, y)` - Long press on word for lookup overlay
|
||||||
|
|
||||||
|
**Overlay Mode**:
|
||||||
|
- `on_chapter_selected(chapter_index)` - Jump to chapter
|
||||||
|
- `on_setting_changed(setting_name, value)` - Update settings
|
||||||
|
- `on_bookmark_selected(bookmark_name)` - Jump to bookmark
|
||||||
|
- `on_word_lookup_action(action, word)` - Handle word lookup actions
|
||||||
|
- Actions: dictionary, xray, highlight, copy
|
||||||
|
- `on_xray_occurrence_selected(position)` - Jump to word occurrence
|
||||||
|
- `on_overlay_close()` - Return to READING mode
|
||||||
|
|
||||||
|
### 3.2 Interactive Elements in Rendered Content
|
||||||
|
For the library view rendered via pyWebLayout:
|
||||||
|
- Each book cover/title should be a clickable element
|
||||||
|
- Use pyWebLayout's link/interactive features to make covers tappable
|
||||||
|
- Query pixel coordinates to determine which book was clicked
|
||||||
|
- Map clicks to book indices
|
||||||
|
|
||||||
|
**Implementation Options**:
|
||||||
|
1. **Bounding Box Approach**: Store bounding boxes for each book element, detect clicks
|
||||||
|
2. **HTML Links**: Embed book paths as links in HTML, use link query API
|
||||||
|
3. **Table Cell IDs**: Use table cell metadata to identify clicked books
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Library Management ✅ (Implemented in book_utils.py + library.py)
|
||||||
|
|
||||||
|
### 4.1 Book Scanning
|
||||||
|
- Scan specified directory for EPUB files
|
||||||
|
- Extract metadata (title, author) using pyWebLayout's EPUB reader
|
||||||
|
- Extract cover images (or use first page as fallback)
|
||||||
|
- Cache metadata to avoid re-scanning on every boot
|
||||||
|
- Support for incremental updates (only scan new/modified files)
|
||||||
|
|
||||||
|
### 4.2 Cover Image Handling
|
||||||
|
**Options**:
|
||||||
|
1. **Base64 Embedded**: Encode covers as base64 in HTML (current approach)
|
||||||
|
2. **File-based**: Save covers to cache directory, reference by path
|
||||||
|
3. **Hybrid**: Cache on disk, fall back to base64 for generation
|
||||||
|
|
||||||
|
**Recommendation**: File-based caching
|
||||||
|
- Faster page generation
|
||||||
|
- Less memory usage
|
||||||
|
- Reusable across sessions
|
||||||
|
|
||||||
|
### 4.3 Library Cache Structure
|
||||||
|
```
|
||||||
|
~/.config/dreader/
|
||||||
|
├── state.json # Application state
|
||||||
|
├── covers/ # Cached cover images
|
||||||
|
│ ├── book1_cover.png
|
||||||
|
│ ├── book2_cover.png
|
||||||
|
│ └── ...
|
||||||
|
├── bookmarks/ # Bookmark data per book
|
||||||
|
│ ├── book1.json
|
||||||
|
│ └── book2.json
|
||||||
|
├── highlights/ # Highlight data per book
|
||||||
|
│ ├── book1.json
|
||||||
|
│ └── book2.json
|
||||||
|
└── xray/ # X-Ray data per book (LLM-generated)
|
||||||
|
├── book1_xray.json
|
||||||
|
└── book2_xray.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**X-Ray Data Format Example**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"book_id": "sherlock_holmes_adventures",
|
||||||
|
"generated_date": "2025-11-08",
|
||||||
|
"entities": {
|
||||||
|
"Sherlock Holmes": {
|
||||||
|
"type": "character",
|
||||||
|
"chapters": {
|
||||||
|
"1": {
|
||||||
|
"summary": "Introduced as a consulting detective...",
|
||||||
|
"relationships": ["Dr. Watson"],
|
||||||
|
"traits": ["observant", "logical"],
|
||||||
|
"key_events": ["Met Watson", "Moved to Baker Street"]
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"summary": "Demonstrates deductive method...",
|
||||||
|
"relationships": ["Dr. Watson", "Inspector Lestrade"],
|
||||||
|
"traits": ["observant", "logical", "unconventional"],
|
||||||
|
"key_events": ["Solved first case"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"221B Baker Street": {
|
||||||
|
"type": "place",
|
||||||
|
"chapters": {
|
||||||
|
"1": {
|
||||||
|
"summary": "Holmes and Watson's shared lodgings",
|
||||||
|
"significance": "Home base for investigations"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technical Architecture ✅ (Core modules implemented)
|
||||||
|
|
||||||
|
### 5.1 Module Structure
|
||||||
|
```
|
||||||
|
dreader/
|
||||||
|
├── __init__.py # ✅ Exports
|
||||||
|
├── application.py # ✅ EbookReader class with gesture handling
|
||||||
|
├── state.py # ✅ State management with asyncio auto-save
|
||||||
|
├── library.py # ✅ Library mode logic with interactive selection
|
||||||
|
├── book_utils.py # ✅ Book scanning utilities
|
||||||
|
├── html_generator.py # ✅ HTML generation for UI
|
||||||
|
├── gesture.py # ✅ Touch/gesture event handling
|
||||||
|
└── overlay.py # ✅ Overlay rendering and compositing
|
||||||
|
```
|
||||||
|
**Note**: No separate callbacks.py - gesture handling integrated into application.py via handle_touch() method
|
||||||
|
|
||||||
|
### 5.2 State Manager API (`dreader/state.py`)
|
||||||
|
```python
|
||||||
|
class StateManager:
|
||||||
|
def __init__(self, state_file_path: str)
|
||||||
|
def load_state() -> AppState
|
||||||
|
def save_state(state: AppState)
|
||||||
|
def get_current_mode() -> EreaderMode
|
||||||
|
def set_mode(mode: EreaderMode)
|
||||||
|
def get_current_book() -> Optional[BookState]
|
||||||
|
def set_current_book(book: BookState)
|
||||||
|
def get_settings() -> Settings
|
||||||
|
def update_setting(key: str, value: Any)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Library Manager API (`dreader/library.py`)
|
||||||
|
```python
|
||||||
|
class LibraryManager:
|
||||||
|
def __init__(self, library_path: str, cache_dir: str)
|
||||||
|
def scan_library() -> List[Book]
|
||||||
|
def get_cached_metadata() -> List[Book]
|
||||||
|
def refresh_metadata(book_path: str)
|
||||||
|
def get_book_at_index(index: int) -> Optional[Book]
|
||||||
|
def render_library_view() -> Image.Image
|
||||||
|
def handle_library_click(x: int, y: int) -> Optional[str] # Returns book path
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Callback Manager API (`dreader/callbacks.py`)
|
||||||
|
```python
|
||||||
|
class CallbackManager:
|
||||||
|
def __init__(self, reader: EbookReader, state_manager: StateManager, library_manager: LibraryManager)
|
||||||
|
def handle_interaction(event: InteractionEvent) -> Response
|
||||||
|
def on_book_selected(book_path: str)
|
||||||
|
def on_page_turn(direction: str)
|
||||||
|
def on_overlay_open(overlay_type: OverlayState)
|
||||||
|
def on_overlay_close()
|
||||||
|
def on_settings_change(setting: str, value: Any)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. HAL (Hardware Abstraction Layer) Integration
|
||||||
|
|
||||||
|
### 6.1 HAL Responsibilities
|
||||||
|
The HAL will handle:
|
||||||
|
- Rendering images to the display
|
||||||
|
- Capturing touch/click input
|
||||||
|
- Sending interaction events to the application
|
||||||
|
- Hardware-specific features (brightness control, WiFi, etc.)
|
||||||
|
|
||||||
|
### 6.2 Application ↔ HAL Interface
|
||||||
|
**From Application to HAL**:
|
||||||
|
- `render_image(image: PIL.Image, layer: str)` - Display image on specified layer
|
||||||
|
- Layers: "base" (page/library), "overlay" (settings/TOC/etc.)
|
||||||
|
- `set_brightness(level: int)` - Adjust screen brightness
|
||||||
|
- `show_loading(message: str)` - Display loading indicator
|
||||||
|
|
||||||
|
**From HAL to Application**:
|
||||||
|
- `on_touch(x: int, y: int, gesture: GestureType)` - Touch event
|
||||||
|
- `on_button_press(button_id: str)` - Hardware button press (if any)
|
||||||
|
- `on_power_event(event: PowerEvent)` - Sleep/wake/shutdown
|
||||||
|
|
||||||
|
### 6.3 Multi-Layer Rendering
|
||||||
|
To support overlays efficiently:
|
||||||
|
1. **Base Layer**: Main content (library view or reading page)
|
||||||
|
2. **Overlay Layer**: Semi-transparent overlays (TOC, settings, etc.)
|
||||||
|
3. **HAL Compositing**: HAL combines layers for display
|
||||||
|
|
||||||
|
**Alternative**: Application composites layers and sends single image to HAL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Future Enhancements
|
||||||
|
|
||||||
|
### 7.1 Phase 2 Features
|
||||||
|
- Full-text search within books
|
||||||
|
- ✅ Highlighting and annotations (basic implementation exists)
|
||||||
|
- Word lookup overlay with dictionary and X-Ray features
|
||||||
|
- Night/sepia reading themes
|
||||||
|
- WiFi configuration UI
|
||||||
|
- OTA (over-the-air) updates
|
||||||
|
- Cloud sync for bookmarks/progress
|
||||||
|
- PDF support
|
||||||
|
|
||||||
|
### 7.2 Phase 3 Features
|
||||||
|
- Multiple library folders
|
||||||
|
- Collections/categories
|
||||||
|
- Reading statistics
|
||||||
|
- Social features (sharing quotes, etc.)
|
||||||
|
- Text-to-speech
|
||||||
|
- Translation support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing Requirements
|
||||||
|
|
||||||
|
### 8.1 Unit Tests
|
||||||
|
- State serialization/deserialization
|
||||||
|
- State file corruption handling
|
||||||
|
- Library scanning with various EPUB formats
|
||||||
|
- Callback routing logic
|
||||||
|
- Click detection and bounding box calculations
|
||||||
|
|
||||||
|
### 8.2 Integration Tests
|
||||||
|
- Full mode transitions (LIBRARY → READING → OVERLAY → READING → LIBRARY)
|
||||||
|
- State persistence across "reboots" (save → load → verify)
|
||||||
|
- Multi-book workflow (open book A, switch to book B, resume book A)
|
||||||
|
- Settings changes reflected in rendering
|
||||||
|
|
||||||
|
### 8.3 Manual/UI Tests
|
||||||
|
- Touch responsiveness on target hardware
|
||||||
|
- Overlay appearance and dismissal
|
||||||
|
- Page turn performance
|
||||||
|
- Library scrolling smoothness
|
||||||
|
- Boot time from power-on to usable state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance Requirements
|
||||||
|
|
||||||
|
- **Boot Time**: < 3 seconds to display library or resume reading
|
||||||
|
- **Page Turn**: < 200ms from gesture to new page display
|
||||||
|
- **Library Load**: < 1 second for libraries up to 100 books
|
||||||
|
- **State Save**: < 50ms (non-blocking if possible)
|
||||||
|
- **Memory**: < 100MB RAM for typical book (depends on images)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Priority
|
||||||
|
|
||||||
|
### Phase 1 (MVP):
|
||||||
|
1. ✅ Basic EbookReader class (application.py - complete)
|
||||||
|
2. ✅ HTML generation for reading view (html_generator.py - complete)
|
||||||
|
3. ✅ Book scanning and metadata extraction (book_utils.py - complete)
|
||||||
|
4. ✅ State management module with persistence (state.py - complete)
|
||||||
|
5. ✅ Library manager with clickable book selection (library.py - complete)
|
||||||
|
6. ✅ Mode transitions (LIBRARY ↔ READING) - **FULLY DEMONSTRATED** in [examples/library_reading_integration.py](examples/library_reading_integration.py)
|
||||||
|
7. ✅ Basic overlays (TOC, Settings) - overlay.py + application.py methods complete
|
||||||
|
8. ✅ Gesture/touch handling (gesture.py - complete with TouchEvent, GestureType, GestureResponse)
|
||||||
|
9. ✅ Full application integration - **WORKING EXAMPLES EXIST**, needs production main.py controller
|
||||||
|
|
||||||
|
### Phase 2:
|
||||||
|
1. ✅ Bookmarks overlay with management (application.py:1351-1377 - complete)
|
||||||
|
2. ✅ Settings persistence and real-time updates (FULLY COMPLETE)
|
||||||
|
- ✅ Settings overlay with real-time preview (application.py:1318-1349)
|
||||||
|
- ✅ Settings saved to state.json (state.py:79-103)
|
||||||
|
- ✅ Settings restored on app startup via apply_settings() (application.py:725-765)
|
||||||
|
- ✅ Includes: font_scale, line_spacing, inter_block_spacing, word_spacing
|
||||||
|
- ✅ Example: examples/persistent_settings_example.py
|
||||||
|
3. ✅ Boot recovery and resume functionality (FULLY TESTED)
|
||||||
|
- ✅ Position restoration via __auto_resume__ bookmark (tests/test_boot_recovery.py:49-98)
|
||||||
|
- ✅ Settings restoration across sessions (tests/test_boot_recovery.py:100-153)
|
||||||
|
- ✅ Bookmark persistence (tests/test_boot_recovery.py:155-200)
|
||||||
|
- ✅ Full state workflow (tests/test_boot_recovery.py:202-277)
|
||||||
|
- ✅ Multiple books with separate state (tests/test_boot_recovery.py:279-343)
|
||||||
|
- ✅ Corrupt state file recovery (tests/test_boot_recovery.py:345-362)
|
||||||
|
- ✅ Missing book handling (tests/test_boot_recovery.py:364-393)
|
||||||
|
- ✅ Cold start with no state (tests/test_boot_recovery.py:395-414)
|
||||||
|
- ✅ Async auto-save functionality (tests/test_boot_recovery.py:516-586)
|
||||||
|
4. 🚧 Error handling and user feedback
|
||||||
|
5. 🚧 Performance optimization
|
||||||
|
6. 🔜 Word lookup overlay (Dictionary + X-Ray feature) - spec complete, not implemented
|
||||||
|
7. ✅ Unified navigation overlay (TOC + Bookmarks tabs) - COMPLETE
|
||||||
|
- ✅ Tabbed interface combining Contents and Bookmarks (html_generator.py:507-637)
|
||||||
|
- ✅ Tab switching without closing overlay (overlay.py:459-481, application.py:1172-1186)
|
||||||
|
- ✅ Gesture handling for all interactions (application.py:828-904)
|
||||||
|
- ✅ New OverlayState.NAVIGATION state (state.py:33)
|
||||||
|
- ✅ Tests and example provided (tests/test_navigation_overlay.py, examples/navigation_overlay_example.py)
|
||||||
|
- ✅ Documentation: NAVIGATION_OVERLAY_IMPLEMENTATION.md
|
||||||
|
|
||||||
|
### Phase 3:
|
||||||
|
1. Advanced library features (search, sort, filter)
|
||||||
|
2. Advanced highlighting (color picker, annotation notes)
|
||||||
|
3. Enhanced X-Ray (character relationships, concept maps)
|
||||||
|
4. Themes and customization
|
||||||
|
5. Offline dictionary database
|
||||||
|
6. Word history and vocabulary tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Design Decisions
|
||||||
|
|
||||||
|
### 1. Click Detection in Library View ✓
|
||||||
|
**Decision**: Use pyWebLayout's built-in `is_interactive` and `link_target` properties
|
||||||
|
- Each book element is marked as interactive with book path as link_target
|
||||||
|
- Query pixel on tap to get clicked book
|
||||||
|
- Same pattern as in-book link handling (application.py:748-769)
|
||||||
|
|
||||||
|
### 2. Multi-Layer Rendering ✓
|
||||||
|
**Decision**: Application-side compositing
|
||||||
|
- Application renders base layer (library or reading page)
|
||||||
|
- For overlays: Composite smaller overlay image onto base layer
|
||||||
|
- Remove overlay by re-rendering the existing base page
|
||||||
|
- On settings change: Re-render background, then re-composite overlay if active
|
||||||
|
- Single final image sent to HAL for display
|
||||||
|
|
||||||
|
### 3. State Save Frequency ✓
|
||||||
|
**Decision**: Timer-based with asyncio
|
||||||
|
- Auto-save every 60 seconds (configurable)
|
||||||
|
- Immediate save on:
|
||||||
|
- Book closed
|
||||||
|
- Device shutdown
|
||||||
|
- Mode change
|
||||||
|
- Settings change
|
||||||
|
- Use asyncio timer loop for automatic saves (non-blocking)
|
||||||
|
|
||||||
|
### 4. Cover Image Strategy
|
||||||
|
**Decision**: File-based cache (preferred)
|
||||||
|
- Cache covers to `~/.config/dreader/covers/`
|
||||||
|
- Generate on first scan, reuse on subsequent boots
|
||||||
|
- Fallback to base64 if cache unavailable
|
||||||
|
|
||||||
|
### 5. HAL Integration
|
||||||
|
**Decision**: HAL provides gesture recognition
|
||||||
|
- HAL sends TouchEvent with GestureType (TAP, SWIPE, etc.)
|
||||||
|
- Application handles business logic via gesture handlers
|
||||||
|
- Application returns GestureResponse with action type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Main Application Controller (Final Integration Piece)
|
||||||
|
|
||||||
|
### 11.1 Current Status
|
||||||
|
All core components are **fully functional and tested** with working integration examples. What's needed is a production-ready main application controller to orchestrate these components.
|
||||||
|
|
||||||
|
### 11.2 Reference Implementation
|
||||||
|
The file [examples/library_reading_integration.py](examples/library_reading_integration.py) demonstrates the complete integration pattern and serves as a reference for building the main controller.
|
||||||
|
|
||||||
|
### 11.3 Required Main Controller Components
|
||||||
|
|
||||||
|
#### A. Application Class Structure
|
||||||
|
```python
|
||||||
|
class DReaderApplication:
|
||||||
|
"""
|
||||||
|
Main application controller coordinating library and reading modes.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Mode management (LIBRARY ↔ READING transitions)
|
||||||
|
- Component lifecycle (LibraryManager, EbookReader)
|
||||||
|
- State persistence integration
|
||||||
|
- Event routing to appropriate handlers
|
||||||
|
- Display updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig):
|
||||||
|
# State management
|
||||||
|
self.state_manager = StateManager()
|
||||||
|
self.state = self.state_manager.load_state()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Initialize and show initial screen based on saved state"""
|
||||||
|
|
||||||
|
def handle_touch(self, event: TouchEvent):
|
||||||
|
"""Route touch events to library or reader based on mode"""
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Clean shutdown with state preservation"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Mode Transition Logic
|
||||||
|
```python
|
||||||
|
def _enter_library_mode(self):
|
||||||
|
"""Switch to library browsing"""
|
||||||
|
# 1. Save and close reader if active
|
||||||
|
if self.reader:
|
||||||
|
self.reader.save_position("__auto_resume__")
|
||||||
|
self.reader.close()
|
||||||
|
self.reader = None
|
||||||
|
|
||||||
|
# 2. Initialize library
|
||||||
|
if not self.library:
|
||||||
|
self.library = LibraryManager(...)
|
||||||
|
|
||||||
|
# 3. Render library view
|
||||||
|
self.current_image = self.library.render_library()
|
||||||
|
self.state_manager.set_mode(EreaderMode.LIBRARY)
|
||||||
|
self._update_display()
|
||||||
|
|
||||||
|
def _enter_reading_mode(self, book_path: str):
|
||||||
|
"""Switch to reading mode"""
|
||||||
|
# 1. Initialize reader if needed
|
||||||
|
if not self.reader:
|
||||||
|
self.reader = EbookReader(...)
|
||||||
|
|
||||||
|
# 2. Load book
|
||||||
|
self.reader.load_epub(book_path)
|
||||||
|
|
||||||
|
# 3. Apply saved settings
|
||||||
|
self.reader.apply_settings(self.state.settings.to_dict())
|
||||||
|
|
||||||
|
# 4. Restore position
|
||||||
|
self.reader.load_position("__auto_resume__")
|
||||||
|
|
||||||
|
# 5. Update state
|
||||||
|
self.state_manager.set_current_book(BookState(...))
|
||||||
|
self.state_manager.set_mode(EreaderMode.READING)
|
||||||
|
|
||||||
|
# 6. Render page
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
self._update_display()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Event Handling Integration
|
||||||
|
```python
|
||||||
|
def handle_touch(self, event: TouchEvent):
|
||||||
|
"""Process touch based on current mode"""
|
||||||
|
if self.state.mode == EreaderMode.LIBRARY:
|
||||||
|
self._handle_library_touch(event)
|
||||||
|
elif self.state.mode == EreaderMode.READING:
|
||||||
|
self._handle_reading_touch(event)
|
||||||
|
|
||||||
|
self._update_display()
|
||||||
|
|
||||||
|
def _handle_library_touch(self, event: TouchEvent):
|
||||||
|
"""Library mode touch handling"""
|
||||||
|
if event.gesture == GestureType.TAP:
|
||||||
|
book_path = self.library.handle_library_tap(event.x, event.y)
|
||||||
|
if book_path:
|
||||||
|
self._enter_reading_mode(book_path)
|
||||||
|
|
||||||
|
def _handle_reading_touch(self, event: TouchEvent):
|
||||||
|
"""Reading mode touch handling"""
|
||||||
|
response = self.reader.handle_touch(event)
|
||||||
|
|
||||||
|
# Handle special actions
|
||||||
|
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||||
|
self._enter_library_mode()
|
||||||
|
elif response.action == ActionType.PAGE_TURN:
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
elif response.action == ActionType.OVERLAY_OPENED:
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
elif response.action == ActionType.SETTING_CHANGED:
|
||||||
|
# Settings changed, update state
|
||||||
|
settings = self.reader.get_current_settings()
|
||||||
|
self.state_manager.update_settings(settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D. State Persistence Integration
|
||||||
|
```python
|
||||||
|
def start(self):
|
||||||
|
"""Boot sequence with state restoration"""
|
||||||
|
# 1. Start auto-save
|
||||||
|
self.state_manager.start_auto_save()
|
||||||
|
|
||||||
|
# 2. Restore previous mode
|
||||||
|
if self.state.mode == EreaderMode.READING and self.state.current_book:
|
||||||
|
# Resume reading last book
|
||||||
|
self._enter_reading_mode(self.state.current_book.path)
|
||||||
|
else:
|
||||||
|
# Show library
|
||||||
|
self._enter_library_mode()
|
||||||
|
|
||||||
|
# 3. Display initial screen
|
||||||
|
self._update_display()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Graceful shutdown"""
|
||||||
|
# 1. Save current position if reading
|
||||||
|
if self.reader and self.reader.is_loaded():
|
||||||
|
self.reader.save_position("__auto_resume__")
|
||||||
|
self.reader.close()
|
||||||
|
|
||||||
|
# 2. Stop auto-save and save final state
|
||||||
|
asyncio.run(self.state_manager.stop_auto_save(save_final=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 Display HAL Interface
|
||||||
|
|
||||||
|
The main controller needs a display abstraction layer (HAL). This is **platform-specific** and not part of the core dreader library.
|
||||||
|
|
||||||
|
**Required HAL Interface:**
|
||||||
|
```python
|
||||||
|
class DisplayHAL(ABC):
|
||||||
|
"""Abstract display interface for platform integration"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def show_image(self, image: Image.Image):
|
||||||
|
"""Display a PIL Image on the screen"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_touch_events(self) -> Iterator[TouchEvent]:
|
||||||
|
"""Get iterator of touch events from hardware"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_brightness(self, level: int):
|
||||||
|
"""Set display brightness (0-10)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Implementations:**
|
||||||
|
- **E-Ink Display**: Use device-specific SDK (e.g., IT8951, Remarkable)
|
||||||
|
- **Desktop Testing**: Use PIL + tkinter or pygame
|
||||||
|
- **Web Interface**: Use Flask + HTML canvas
|
||||||
|
- **Qt Application**: Use QPixmap + QTouchEvent
|
||||||
|
|
||||||
|
### 11.5 Implementation Checklist
|
||||||
|
|
||||||
|
To complete the main application controller:
|
||||||
|
|
||||||
|
- [ ] Create `dreader/main.py` with `DReaderApplication` class
|
||||||
|
- [ ] Implement mode transition methods (`_enter_library_mode`, `_enter_reading_mode`)
|
||||||
|
- [ ] Implement event routing (`handle_touch`)
|
||||||
|
- [ ] Implement boot/resume logic (`start`)
|
||||||
|
- [ ] Implement graceful shutdown (`shutdown`)
|
||||||
|
- [ ] Create platform-specific DisplayHAL implementation
|
||||||
|
- [ ] Create configuration system (AppConfig class)
|
||||||
|
- [ ] Add command-line argument parsing
|
||||||
|
- [ ] Create systemd service file (for embedded systems)
|
||||||
|
- [ ] Add logging throughout controller
|
||||||
|
- [ ] Write integration tests for main controller
|
||||||
|
- [ ] Create user documentation
|
||||||
|
|
||||||
|
### 11.6 Platform-Specific Entry Points
|
||||||
|
|
||||||
|
Once the main controller exists, create platform-specific entry points:
|
||||||
|
|
||||||
|
**Desktop (Pygame):**
|
||||||
|
```python
|
||||||
|
# desktop_app.py
|
||||||
|
from dreader.main import DReaderApplication
|
||||||
|
from hal.pygame_display import PygameDisplayHAL
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = AppConfig(
|
||||||
|
display_hal=PygameDisplayHAL(width=800, height=1200),
|
||||||
|
library_path="~/Books",
|
||||||
|
page_size=(800, 1200)
|
||||||
|
)
|
||||||
|
|
||||||
|
app = DReaderApplication(config)
|
||||||
|
app.start()
|
||||||
|
|
||||||
|
# Event loop
|
||||||
|
running = True
|
||||||
|
while running:
|
||||||
|
for event in app.display_hal.get_touch_events():
|
||||||
|
app.handle_touch(event)
|
||||||
|
|
||||||
|
app.shutdown()
|
||||||
|
```
|
||||||
|
|
||||||
|
**E-Ink Device:**
|
||||||
|
```python
|
||||||
|
# eink_app.py
|
||||||
|
from dreader.main import DReaderApplication
|
||||||
|
from hal.eink_display import EinkDisplayHAL
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = AppConfig(
|
||||||
|
display_hal=EinkDisplayHAL(device="/dev/epd"),
|
||||||
|
library_path="/mnt/books",
|
||||||
|
page_size=(1200, 1600)
|
||||||
|
)
|
||||||
|
|
||||||
|
app = DReaderApplication(config)
|
||||||
|
app.start()
|
||||||
|
|
||||||
|
# Hardware event loop
|
||||||
|
app.display_hal.run_event_loop(app.handle_touch)
|
||||||
|
|
||||||
|
app.shutdown()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Interface:**
|
||||||
|
```python
|
||||||
|
# web_app.py
|
||||||
|
from flask import Flask, render_template, jsonify, request
|
||||||
|
from dreader.main import DReaderApplication
|
||||||
|
from hal.web_display import WebDisplayHAL
|
||||||
|
|
||||||
|
app = DReaderApplication(...)
|
||||||
|
|
||||||
|
@app.route('/touch', methods=['POST'])
|
||||||
|
def handle_touch():
|
||||||
|
x, y = request.json['x'], request.json['y']
|
||||||
|
gesture = request.json['gesture']
|
||||||
|
event = TouchEvent(gesture, x, y)
|
||||||
|
app.handle_touch(event)
|
||||||
|
return jsonify(success=True)
|
||||||
|
|
||||||
|
@app.route('/current_image')
|
||||||
|
def get_image():
|
||||||
|
return send_image(app.current_image)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The implementation is complete when:
|
||||||
|
1. User can browse library and select a book with a single tap
|
||||||
|
2. Selected book opens at last read position (or first page if new)
|
||||||
|
3. User can turn pages forward/backward via tap or swipe
|
||||||
|
4. User can open TOC and jump to any chapter
|
||||||
|
5. User can adjust font size and see changes immediately
|
||||||
|
6. User can add/remove bookmarks and navigate to them
|
||||||
|
7. Application state persists across reboots
|
||||||
|
8. Application resumes exactly where user left off after power cycle
|
||||||
|
9. All features work on target hardware with acceptable performance
|
||||||
|
10. No data loss on unexpected shutdown/crash
|
||||||
207
debug_overlay_links.py
Normal file
207
debug_overlay_links.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug script to visualize interactive elements in overlays.
|
||||||
|
Shows where clickable links are located.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).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
debug_previous_page.py
Normal file
88
debug_previous_page.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug previous_page issue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).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: 1.9 MiB After Width: | Height: | Size: 378 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 543 KiB After Width: | Height: | Size: 407 KiB |
@ -25,6 +25,8 @@ from dreader.state import (
|
|||||||
)
|
)
|
||||||
from dreader.library import LibraryManager
|
from dreader.library import LibraryManager
|
||||||
from dreader.overlay import OverlayManager
|
from dreader.overlay import OverlayManager
|
||||||
|
from dreader.main import DReaderApplication, AppConfig
|
||||||
|
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -56,4 +58,13 @@ __all__ = [
|
|||||||
|
|
||||||
# Overlay
|
# Overlay
|
||||||
"OverlayManager",
|
"OverlayManager",
|
||||||
|
|
||||||
|
# Main application
|
||||||
|
"DReaderApplication",
|
||||||
|
"AppConfig",
|
||||||
|
|
||||||
|
# HAL interfaces
|
||||||
|
"DisplayHAL",
|
||||||
|
"KeyboardInputHAL",
|
||||||
|
"EventLoopHAL",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -47,7 +47,7 @@ from pyWebLayout.layout.ereader_layout import RenderingPosition
|
|||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
from pyWebLayout.concrete.page import Page
|
from pyWebLayout.concrete.page import Page
|
||||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||||
from pyWebLayout.core.highlight import Highlight, HighlightColor
|
from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlight_from_query_result
|
||||||
|
|
||||||
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||||
from .state import OverlayState
|
from .state import OverlayState
|
||||||
|
|||||||
188
dreader/hal.py
Normal file
188
dreader/hal.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
Hardware Abstraction Layer (HAL) interface for DReader.
|
||||||
|
|
||||||
|
This module defines the abstract interface that platform-specific
|
||||||
|
display/input implementations must provide.
|
||||||
|
|
||||||
|
The HAL separates the core e-reader logic from platform-specific
|
||||||
|
hardware details (display, touch input, buttons, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import AsyncIterator, Optional
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .gesture import TouchEvent
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayHAL(ABC):
|
||||||
|
"""
|
||||||
|
Abstract interface for display and input hardware.
|
||||||
|
|
||||||
|
Platform-specific implementations should subclass this and provide
|
||||||
|
concrete implementations for all abstract methods.
|
||||||
|
|
||||||
|
The HAL is responsible for:
|
||||||
|
- Displaying images on the screen
|
||||||
|
- Capturing touch/click input and converting to TouchEvent
|
||||||
|
- Hardware-specific features (brightness, sleep, etc.)
|
||||||
|
|
||||||
|
All methods are async to support non-blocking I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def show_image(self, image: Image.Image):
|
||||||
|
"""
|
||||||
|
Display a PIL Image on the screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: PIL Image to display
|
||||||
|
|
||||||
|
This method should handle:
|
||||||
|
- Converting image format if needed for the display
|
||||||
|
- Scaling/cropping if image size doesn't match display
|
||||||
|
- Updating the physical display hardware
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||||
|
"""
|
||||||
|
Wait for and return the next touch event.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TouchEvent if available, None if no event (non-blocking mode)
|
||||||
|
|
||||||
|
This method should:
|
||||||
|
- Read from touch hardware
|
||||||
|
- Convert raw coordinates to TouchEvent
|
||||||
|
- Detect gesture type (tap, swipe, etc.)
|
||||||
|
- Return None immediately if no event available
|
||||||
|
|
||||||
|
Note: For blocking behavior, implement a loop that awaits this
|
||||||
|
method in the main event loop.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_brightness(self, level: int):
|
||||||
|
"""
|
||||||
|
Set display brightness.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Brightness level (0-10, where 0=dimmest, 10=brightest)
|
||||||
|
|
||||||
|
Platform implementations should map this to their hardware's
|
||||||
|
actual brightness range.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""
|
||||||
|
Initialize the display hardware.
|
||||||
|
|
||||||
|
This optional method is called once before the application starts.
|
||||||
|
Override to perform platform-specific initialization.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
"""
|
||||||
|
Clean up display hardware resources.
|
||||||
|
|
||||||
|
This optional method is called during application shutdown.
|
||||||
|
Override to perform platform-specific cleanup.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def show_message(self, message: str, duration: float = 2.0):
|
||||||
|
"""
|
||||||
|
Display a text message (for loading screens, errors, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Text message to display
|
||||||
|
duration: How long to show message (seconds)
|
||||||
|
|
||||||
|
Default implementation creates a simple text image.
|
||||||
|
Override for platform-specific message display.
|
||||||
|
"""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Create simple text image
|
||||||
|
img = Image.new('RGB', (800, 1200), color=(255, 255, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Try to use a decent font, fall back to default
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||||
|
except:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Draw centered text
|
||||||
|
bbox = draw.textbbox((0, 0), message, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
x = (800 - text_width) // 2
|
||||||
|
y = (1200 - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), message, fill=(0, 0, 0), font=font)
|
||||||
|
|
||||||
|
await self.show_image(img)
|
||||||
|
|
||||||
|
# Wait for duration
|
||||||
|
if duration > 0:
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardInputHAL(ABC):
|
||||||
|
"""
|
||||||
|
Optional abstract interface for keyboard input.
|
||||||
|
|
||||||
|
This is separate from DisplayHAL to support platforms that have
|
||||||
|
both touch and keyboard input (e.g., desktop testing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_key_event(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the next keyboard event.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Key name as string (e.g., "up", "down", "enter", "q")
|
||||||
|
None if no key event available
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EventLoopHAL(DisplayHAL):
|
||||||
|
"""
|
||||||
|
Extended HAL interface that provides its own event loop.
|
||||||
|
|
||||||
|
Some platforms (e.g., Pygame, Qt) have their own event loop that
|
||||||
|
must be used. This interface allows the HAL to run the main loop
|
||||||
|
and call back to the application.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
hal = MyEventLoopHAL()
|
||||||
|
app = DReaderApplication(AppConfig(display_hal=hal, ...))
|
||||||
|
|
||||||
|
await hal.run_event_loop(app)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run_event_loop(self, app):
|
||||||
|
"""
|
||||||
|
Run the platform's event loop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: DReaderApplication instance to send events to
|
||||||
|
|
||||||
|
This method should:
|
||||||
|
1. Initialize the display
|
||||||
|
2. Call app.start()
|
||||||
|
3. Enter event loop
|
||||||
|
4. Call app.handle_touch(event) for each event
|
||||||
|
5. Handle quit events and call app.shutdown()
|
||||||
|
"""
|
||||||
|
pass
|
||||||
401
dreader/hal_pygame.py
Normal file
401
dreader/hal_pygame.py
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
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()
|
||||||
@ -234,28 +234,21 @@ class GestureRouter:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||||
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
|
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
|
||||||
# Check if swipe started from bottom 20% of screen
|
# Open navigation overlay from anywhere on screen
|
||||||
bottom_threshold = self.reader.page_size[1] * 0.8
|
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
|
||||||
|
|
||||||
if y >= bottom_threshold:
|
|
||||||
# Open TOC overlay
|
|
||||||
overlay_image = self.reader.open_toc_overlay()
|
|
||||||
if overlay_image:
|
if overlay_image:
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||||
"overlay_type": "toc",
|
"overlay_type": "navigation",
|
||||||
|
"active_tab": "contents",
|
||||||
"chapters": self.reader.get_chapters()
|
"chapters": self.reader.get_chapters()
|
||||||
})
|
})
|
||||||
|
|
||||||
return GestureResponse(ActionType.NONE, {})
|
return GestureResponse(ActionType.NONE, {})
|
||||||
|
|
||||||
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
def _handle_swipe_down(self, y: int) -> GestureResponse:
|
||||||
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
|
"""Handle swipe down gesture - opens Settings overlay"""
|
||||||
# Check if swipe started from top 20% of screen
|
# Open settings overlay from anywhere on screen
|
||||||
top_threshold = self.reader.page_size[1] * 0.2
|
|
||||||
|
|
||||||
if y <= top_threshold:
|
|
||||||
# Open settings overlay
|
|
||||||
overlay_image = self.reader.open_settings_overlay()
|
overlay_image = self.reader.open_settings_overlay()
|
||||||
if overlay_image:
|
if overlay_image:
|
||||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||||
|
|||||||
@ -240,47 +240,47 @@ def generate_settings_overlay(
|
|||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
|
||||||
<b>Font Size: {font_percent}%</b>
|
<b>Font Size: {font_percent}%</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:font_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
|
||||||
<b>Line Spacing: {line_spacing}px</b>
|
<b>Line Spacing: {line_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
|
||||||
<b>Paragraph Spacing: {inter_block_spacing}px</b>
|
<b>Paragraph Spacing: {inter_block_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
|
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
|
||||||
<b>Word Spacing: {word_spacing}px</b>
|
<b>Word Spacing: {word_spacing}px</b>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
|
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
|
<p style="margin: 5px 0; background-color: #f0f0f0;">
|
||||||
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
|
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin: 20px 0;">
|
<div style="margin: 20px 0;">
|
||||||
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
<p style="margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
||||||
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">◄ Back to Library</a>
|
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px; display: block; padding: 15px;">◄ Back to Library</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -538,9 +538,8 @@ def generate_navigation_overlay(
|
|||||||
link_text = f'{i+1}. {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="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
||||||
f'border-left: 3px solid #000;">'
|
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
||||||
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
|
|
||||||
f'{link_text}</a></p>'
|
f'{link_text}</a></p>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -551,9 +550,8 @@ def generate_navigation_overlay(
|
|||||||
position_text = bookmark.get('position', 'Saved position')
|
position_text = bookmark.get('position', 'Saved position')
|
||||||
|
|
||||||
bookmark_items.append(
|
bookmark_items.append(
|
||||||
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
|
||||||
f'border-left: 3px solid #000;">'
|
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
|
||||||
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>'
|
||||||
|
|||||||
428
dreader/main.py
Normal file
428
dreader/main.py
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
"""
|
||||||
|
Main application controller for DReader e-reader application.
|
||||||
|
|
||||||
|
This module provides the DReaderApplication class which orchestrates:
|
||||||
|
- Library and reading mode transitions
|
||||||
|
- State persistence and recovery
|
||||||
|
- HAL integration for display and input
|
||||||
|
- Event routing and handling
|
||||||
|
|
||||||
|
The application uses asyncio for non-blocking operations and integrates
|
||||||
|
with a hardware abstraction layer (HAL) for platform independence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .library import LibraryManager
|
||||||
|
from .application import EbookReader
|
||||||
|
from .state import StateManager, EreaderMode, OverlayState, BookState
|
||||||
|
from .gesture import TouchEvent, GestureType, ActionType
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig:
|
||||||
|
"""
|
||||||
|
Configuration for DReaderApplication.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
display_hal: Hardware abstraction layer for display/input
|
||||||
|
library_path: Path to directory containing EPUB files
|
||||||
|
page_size: Tuple of (width, height) for rendered pages
|
||||||
|
bookmarks_dir: Directory for bookmark storage (default: ~/.config/dreader/bookmarks)
|
||||||
|
highlights_dir: Directory for highlights storage (default: ~/.config/dreader/highlights)
|
||||||
|
state_file: Path to state JSON file (default: ~/.config/dreader/state.json)
|
||||||
|
auto_save_interval: Seconds between automatic state saves (default: 60)
|
||||||
|
force_library_mode: If True, always start in library mode (default: False)
|
||||||
|
log_level: Logging level (default: logging.INFO)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
display_hal,
|
||||||
|
library_path: str,
|
||||||
|
page_size: tuple[int, int] = (800, 1200),
|
||||||
|
bookmarks_dir: Optional[str] = None,
|
||||||
|
highlights_dir: Optional[str] = None,
|
||||||
|
state_file: Optional[str] = None,
|
||||||
|
auto_save_interval: int = 60,
|
||||||
|
force_library_mode: bool = False,
|
||||||
|
log_level: int = logging.INFO
|
||||||
|
):
|
||||||
|
self.display_hal = display_hal
|
||||||
|
self.library_path = library_path
|
||||||
|
self.page_size = page_size
|
||||||
|
self.force_library_mode = force_library_mode
|
||||||
|
|
||||||
|
# Set up default config paths
|
||||||
|
config_dir = Path.home() / ".config" / "dreader"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.bookmarks_dir = bookmarks_dir or str(config_dir / "bookmarks")
|
||||||
|
self.highlights_dir = highlights_dir or str(config_dir / "highlights")
|
||||||
|
self.state_file = state_file or str(config_dir / "state.json")
|
||||||
|
self.auto_save_interval = auto_save_interval
|
||||||
|
self.log_level = log_level
|
||||||
|
|
||||||
|
|
||||||
|
class DReaderApplication:
|
||||||
|
"""
|
||||||
|
Main application controller coordinating library and reading modes.
|
||||||
|
|
||||||
|
This class orchestrates all major components of the e-reader:
|
||||||
|
- LibraryManager for book browsing
|
||||||
|
- EbookReader for reading books
|
||||||
|
- StateManager for persistence
|
||||||
|
- DisplayHAL for hardware integration
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
config = AppConfig(
|
||||||
|
display_hal=MyDisplayHAL(),
|
||||||
|
library_path="/path/to/books"
|
||||||
|
)
|
||||||
|
|
||||||
|
app = DReaderApplication(config)
|
||||||
|
await app.start()
|
||||||
|
|
||||||
|
# In event loop:
|
||||||
|
await app.handle_touch(touch_event)
|
||||||
|
|
||||||
|
await app.shutdown()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig):
|
||||||
|
"""
|
||||||
|
Initialize the application with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Application configuration
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(level=config.log_level)
|
||||||
|
logger.info("Initializing DReaderApplication")
|
||||||
|
|
||||||
|
# State management
|
||||||
|
self.state_manager = StateManager(
|
||||||
|
state_file=config.state_file,
|
||||||
|
auto_save_interval=config.auto_save_interval
|
||||||
|
)
|
||||||
|
self.state = self.state_manager.load_state()
|
||||||
|
logger.info(f"Loaded state: mode={self.state.mode}, current_book={self.state.current_book}")
|
||||||
|
|
||||||
|
# Components (lazy-initialized)
|
||||||
|
self.library: Optional[LibraryManager] = None
|
||||||
|
self.reader: Optional[EbookReader] = None
|
||||||
|
|
||||||
|
# Display abstraction
|
||||||
|
self.display_hal = config.display_hal
|
||||||
|
self.current_image: Optional[Image.Image] = None
|
||||||
|
|
||||||
|
# Running state
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""
|
||||||
|
Start the application and display initial screen.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Starts automatic state saving
|
||||||
|
2. Restores previous mode or shows library
|
||||||
|
3. Displays the initial screen
|
||||||
|
"""
|
||||||
|
logger.info("Starting DReaderApplication")
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
# Start auto-save
|
||||||
|
self.state_manager.start_auto_save()
|
||||||
|
logger.info(f"Auto-save started (interval: {self.config.auto_save_interval}s)")
|
||||||
|
|
||||||
|
# Restore previous mode (or force library mode if configured)
|
||||||
|
force_library = getattr(self.config, 'force_library_mode', False)
|
||||||
|
|
||||||
|
if force_library:
|
||||||
|
logger.info("Force library mode enabled - starting in library")
|
||||||
|
await self._enter_library_mode()
|
||||||
|
elif self.state.mode == EreaderMode.READING and self.state.current_book:
|
||||||
|
logger.info(f"Resuming reading mode: {self.state.current_book.path}")
|
||||||
|
await self._enter_reading_mode(self.state.current_book.path)
|
||||||
|
else:
|
||||||
|
logger.info("Entering library mode")
|
||||||
|
await self._enter_library_mode()
|
||||||
|
|
||||||
|
# Display initial screen
|
||||||
|
await self._update_display()
|
||||||
|
logger.info("Application started successfully")
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
"""
|
||||||
|
Gracefully shutdown the application.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Saves current reading position
|
||||||
|
2. Closes active components
|
||||||
|
3. Stops auto-save and saves final state
|
||||||
|
"""
|
||||||
|
logger.info("Shutting down DReaderApplication")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# Save current position if reading
|
||||||
|
if self.reader and self.reader.is_loaded():
|
||||||
|
logger.info("Saving auto-resume position")
|
||||||
|
self.reader.save_position("__auto_resume__")
|
||||||
|
self.reader.close()
|
||||||
|
|
||||||
|
# Clean up library
|
||||||
|
if self.library:
|
||||||
|
self.library.cleanup()
|
||||||
|
|
||||||
|
# Stop auto-save and save final state
|
||||||
|
await self.state_manager.stop_auto_save(save_final=True)
|
||||||
|
logger.info("Application shutdown complete")
|
||||||
|
|
||||||
|
async def handle_touch(self, event: TouchEvent):
|
||||||
|
"""
|
||||||
|
Process touch event based on current mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Touch event from HAL
|
||||||
|
"""
|
||||||
|
logger.info(f"[APP] Received touch event: {event.gesture.value} at ({event.x}, {event.y}), mode={self.state.mode.value}")
|
||||||
|
|
||||||
|
if self.state.mode == EreaderMode.LIBRARY:
|
||||||
|
logger.info("[APP] Routing to library touch handler")
|
||||||
|
await self._handle_library_touch(event)
|
||||||
|
elif self.state.mode == EreaderMode.READING:
|
||||||
|
logger.info("[APP] Routing to reading touch handler")
|
||||||
|
await self._handle_reading_touch(event)
|
||||||
|
|
||||||
|
# Update display after handling
|
||||||
|
await self._update_display()
|
||||||
|
|
||||||
|
async def _enter_library_mode(self):
|
||||||
|
"""
|
||||||
|
Switch to library browsing mode.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Saves and closes reader if active
|
||||||
|
2. Initializes library manager
|
||||||
|
3. Renders library view
|
||||||
|
4. Updates state
|
||||||
|
"""
|
||||||
|
logger.info("Entering library mode")
|
||||||
|
|
||||||
|
# Save and close reader if active
|
||||||
|
if self.reader:
|
||||||
|
if self.reader.is_loaded():
|
||||||
|
logger.info("Saving reading position before closing")
|
||||||
|
self.reader.save_position("__auto_resume__")
|
||||||
|
self.reader.close()
|
||||||
|
self.reader = None
|
||||||
|
|
||||||
|
# Initialize library if needed
|
||||||
|
if not self.library:
|
||||||
|
logger.info(f"Initializing library manager: {self.config.library_path}")
|
||||||
|
self.library = LibraryManager(
|
||||||
|
library_path=self.config.library_path,
|
||||||
|
page_size=self.config.page_size,
|
||||||
|
cache_dir=None # Uses default ~/.config/dreader
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scan for books (async operation)
|
||||||
|
logger.info("Scanning library for books")
|
||||||
|
books = self.library.scan_library()
|
||||||
|
logger.info(f"Found {len(books)} books")
|
||||||
|
|
||||||
|
# Render library view
|
||||||
|
logger.info("Rendering library view")
|
||||||
|
self.current_image = self.library.render_library()
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self.state_manager.set_mode(EreaderMode.LIBRARY)
|
||||||
|
logger.info("Library mode active")
|
||||||
|
|
||||||
|
async def _enter_reading_mode(self, book_path: str):
|
||||||
|
"""
|
||||||
|
Switch to reading mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_path: Path to EPUB file to open
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Initializes reader if needed
|
||||||
|
2. Loads the book
|
||||||
|
3. Applies saved settings
|
||||||
|
4. Restores reading position
|
||||||
|
5. Updates state
|
||||||
|
6. Renders first/current page
|
||||||
|
"""
|
||||||
|
logger.info(f"Entering reading mode: {book_path}")
|
||||||
|
|
||||||
|
# Verify book exists
|
||||||
|
if not Path(book_path).exists():
|
||||||
|
logger.error(f"Book not found: {book_path}")
|
||||||
|
# Return to library
|
||||||
|
await self._enter_library_mode()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialize reader if needed
|
||||||
|
if not self.reader:
|
||||||
|
logger.info("Initializing ebook reader")
|
||||||
|
self.reader = EbookReader(
|
||||||
|
page_size=self.config.page_size,
|
||||||
|
margin=40,
|
||||||
|
background_color=(255, 255, 255),
|
||||||
|
bookmarks_dir=self.config.bookmarks_dir,
|
||||||
|
highlights_dir=self.config.highlights_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load book
|
||||||
|
logger.info(f"Loading EPUB: {book_path}")
|
||||||
|
success = self.reader.load_epub(book_path)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error(f"Failed to load EPUB: {book_path}")
|
||||||
|
# Return to library
|
||||||
|
await self._enter_library_mode()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Loaded: {self.reader.book_title} by {self.reader.book_author}")
|
||||||
|
|
||||||
|
# Apply saved settings
|
||||||
|
logger.info("Applying saved settings")
|
||||||
|
settings_dict = self.state.settings.to_dict()
|
||||||
|
self.reader.apply_settings(settings_dict)
|
||||||
|
|
||||||
|
# Restore position
|
||||||
|
logger.info("Restoring reading position")
|
||||||
|
position_loaded = self.reader.load_position("__auto_resume__")
|
||||||
|
if position_loaded:
|
||||||
|
pos_info = self.reader.get_position_info()
|
||||||
|
logger.info(f"Resumed at position: {pos_info}")
|
||||||
|
else:
|
||||||
|
logger.info("No saved position, starting from beginning")
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self.state_manager.set_current_book(BookState(
|
||||||
|
path=book_path,
|
||||||
|
title=self.reader.book_title or "Unknown",
|
||||||
|
author=self.reader.book_author or "Unknown"
|
||||||
|
))
|
||||||
|
self.state_manager.set_mode(EreaderMode.READING)
|
||||||
|
|
||||||
|
# Render current page
|
||||||
|
logger.info("Rendering current page")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
logger.info("Reading mode active")
|
||||||
|
|
||||||
|
async def _handle_library_touch(self, event: TouchEvent):
|
||||||
|
"""
|
||||||
|
Handle touch events in library mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Touch event
|
||||||
|
"""
|
||||||
|
if event.gesture == GestureType.TAP:
|
||||||
|
logger.debug(f"Library tap at ({event.x}, {event.y})")
|
||||||
|
|
||||||
|
# Check if a book was selected
|
||||||
|
book_path = self.library.handle_library_tap(event.x, event.y)
|
||||||
|
|
||||||
|
if book_path:
|
||||||
|
logger.info(f"Book selected: {book_path}")
|
||||||
|
await self._enter_reading_mode(book_path)
|
||||||
|
else:
|
||||||
|
logger.debug("Tap did not hit a book")
|
||||||
|
|
||||||
|
async def _handle_reading_touch(self, event: TouchEvent):
|
||||||
|
"""
|
||||||
|
Handle touch events in reading mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Touch event
|
||||||
|
"""
|
||||||
|
# Delegate to reader's gesture handler
|
||||||
|
logger.info(f"[APP] Calling reader.handle_touch({event.gesture.value})")
|
||||||
|
response = self.reader.handle_touch(event)
|
||||||
|
|
||||||
|
# response.action is already a string (ActionType enum value), not the enum itself
|
||||||
|
logger.info(f"[APP] Reader response: action={response.action}, data={response.data}")
|
||||||
|
|
||||||
|
# Handle special actions
|
||||||
|
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||||
|
logger.info("[APP] → Returning to library")
|
||||||
|
await self._enter_library_mode()
|
||||||
|
|
||||||
|
elif response.action == ActionType.PAGE_TURN:
|
||||||
|
logger.info(f"[APP] → Page turned: {response.data}")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.OVERLAY_OPENED:
|
||||||
|
logger.info(f"[APP] → Overlay opened: {self.reader.get_overlay_state()}")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.OVERLAY_CLOSED:
|
||||||
|
logger.info("[APP] → Overlay closed")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.SETTING_CHANGED:
|
||||||
|
logger.info(f"[APP] → Setting changed: {response.data}")
|
||||||
|
# Update state with new settings
|
||||||
|
settings = self.reader.get_current_settings()
|
||||||
|
self.state_manager.update_settings(settings)
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.CHAPTER_SELECTED:
|
||||||
|
logger.info(f"[APP] → Chapter selected: {response.data}")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.BOOKMARK_SELECTED:
|
||||||
|
logger.info(f"[APP] → Bookmark selected: {response.data}")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.NAVIGATE:
|
||||||
|
logger.debug("Navigation action")
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.ZOOM:
|
||||||
|
logger.info(f"Zoom action: {response.data}")
|
||||||
|
# Font size changed
|
||||||
|
settings = self.reader.get_current_settings()
|
||||||
|
self.state_manager.update_settings(settings)
|
||||||
|
self.current_image = self.reader.get_current_page()
|
||||||
|
|
||||||
|
elif response.action == ActionType.ERROR:
|
||||||
|
logger.error(f"Error: {response.data}")
|
||||||
|
|
||||||
|
async def _update_display(self):
|
||||||
|
"""
|
||||||
|
Update the display with current image.
|
||||||
|
|
||||||
|
This method sends the current image to the HAL for display.
|
||||||
|
"""
|
||||||
|
if self.current_image:
|
||||||
|
logger.debug(f"Updating display: {self.current_image.size}")
|
||||||
|
await self.display_hal.show_image(self.current_image)
|
||||||
|
else:
|
||||||
|
logger.warning("No image to display")
|
||||||
|
|
||||||
|
def get_current_mode(self) -> EreaderMode:
|
||||||
|
"""Get current application mode."""
|
||||||
|
return self.state.mode
|
||||||
|
|
||||||
|
def get_overlay_state(self) -> OverlayState:
|
||||||
|
"""Get current overlay state (only valid in reading mode)."""
|
||||||
|
if self.reader:
|
||||||
|
return self.reader.get_overlay_state()
|
||||||
|
return OverlayState.NONE
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if application is running."""
|
||||||
|
return self.running
|
||||||
@ -168,6 +168,7 @@ class OverlaySubApplication(ABC):
|
|||||||
Composited PIL Image with popup effect
|
Composited PIL Image with popup effect
|
||||||
"""
|
"""
|
||||||
from PIL import ImageDraw, ImageEnhance
|
from PIL import ImageDraw, ImageEnhance
|
||||||
|
import os
|
||||||
|
|
||||||
# Convert base image to RGB
|
# Convert base image to RGB
|
||||||
result = base_page.convert('RGB').copy()
|
result = base_page.convert('RGB').copy()
|
||||||
@ -180,6 +181,11 @@ class OverlaySubApplication(ABC):
|
|||||||
if overlay_panel.mode != 'RGB':
|
if overlay_panel.mode != 'RGB':
|
||||||
overlay_panel = overlay_panel.convert('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
|
# Calculate centered position for the panel
|
||||||
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
|
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
|
||||||
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
|
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
|
||||||
@ -245,6 +251,13 @@ class OverlaySubApplication(ABC):
|
|||||||
# Query the point
|
# Query the point
|
||||||
result = current_page.query_point((overlay_x, overlay_y))
|
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:
|
if not result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -271,3 +284,76 @@ class OverlaySubApplication(ABC):
|
|||||||
panel_width = int(self.page_size[0] * width_ratio)
|
panel_width = int(self.page_size[0] * width_ratio)
|
||||||
panel_height = int(self.page_size[1] * height_ratio)
|
panel_height = int(self.page_size[1] * height_ratio)
|
||||||
return (panel_width, panel_height)
|
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
|
||||||
|
|||||||
@ -107,16 +107,25 @@ class NavigationOverlay(OverlaySubApplication):
|
|||||||
Returns:
|
Returns:
|
||||||
GestureResponse with appropriate action
|
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 the overlay to see what was tapped
|
||||||
query_result = self.query_overlay_pixel(x, y)
|
query_result = self.query_overlay_pixel(x, y)
|
||||||
|
|
||||||
# If query failed (tap outside overlay), close it
|
logger.info(f"[NAV_OVERLAY] Query result: {query_result}")
|
||||||
if not 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, {})
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
# Check if tapped on a link
|
# Check if tapped on a link
|
||||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||||
link_target = query_result["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
|
# Parse "tab:tabname" format for tab switching
|
||||||
if link_target.startswith("tab:"):
|
if link_target.startswith("tab:"):
|
||||||
@ -168,10 +177,12 @@ class NavigationOverlay(OverlaySubApplication):
|
|||||||
elif link_target.startswith("action:"):
|
elif link_target.startswith("action:"):
|
||||||
action = link_target.split(":", 1)[1]
|
action = link_target.split(":", 1)[1]
|
||||||
if action == "close":
|
if action == "close":
|
||||||
|
logger.info(f"[NAV_OVERLAY] Close button clicked")
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
# Not an interactive element, close overlay
|
# Tap inside overlay but not on interactive element - keep overlay open
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
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]:
|
def switch_tab(self, new_tab: str) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -90,20 +90,30 @@ class SettingsOverlay(OverlaySubApplication):
|
|||||||
Returns:
|
Returns:
|
||||||
GestureResponse with appropriate action
|
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 the overlay to see what was tapped
|
||||||
query_result = self.query_overlay_pixel(x, y)
|
query_result = self.query_overlay_pixel(x, y)
|
||||||
|
|
||||||
# If query failed (tap outside overlay), close it
|
logger.info(f"[SETTINGS_OVERLAY] Query result: {query_result}")
|
||||||
if not 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, {})
|
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||||
|
|
||||||
# Check if tapped on a settings control link
|
# Check if tapped on a settings control link
|
||||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||||
link_target = query_result["link_target"]
|
link_target = query_result["link_target"]
|
||||||
|
logger.info(f"[SETTINGS_OVERLAY] Found interactive link: {link_target}")
|
||||||
|
|
||||||
# Parse "setting:action" format
|
# Parse "setting:action" format
|
||||||
if link_target.startswith("setting:"):
|
if link_target.startswith("setting:"):
|
||||||
action = link_target.split(":", 1)[1]
|
action = link_target.split(":", 1)[1]
|
||||||
|
logger.info(f"[SETTINGS_OVERLAY] Applying setting change: {action}")
|
||||||
return self._apply_setting_change(action)
|
return self._apply_setting_change(action)
|
||||||
|
|
||||||
# Parse "action:command" format for other actions
|
# Parse "action:command" format for other actions
|
||||||
@ -111,10 +121,12 @@ class SettingsOverlay(OverlaySubApplication):
|
|||||||
action = link_target.split(":", 1)[1]
|
action = link_target.split(":", 1)[1]
|
||||||
|
|
||||||
if action == "back_to_library":
|
if action == "back_to_library":
|
||||||
|
logger.info(f"[SETTINGS_OVERLAY] Back to library clicked")
|
||||||
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
|
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
|
||||||
|
|
||||||
# Not a setting control, close overlay
|
# Tap inside overlay but not on interactive element - keep overlay open
|
||||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
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,
|
def refresh(self, updated_base_page: Image.Image,
|
||||||
font_scale: float,
|
font_scale: float,
|
||||||
|
|||||||
188
run_dreader.py
Executable file
188
run_dreader.py
Executable file
@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
DReader E-Book Reader - Main Entry Point
|
||||||
|
|
||||||
|
This script launches the DReader application with a Pygame-based
|
||||||
|
desktop HAL for testing and development.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run_dreader.py [library_path]
|
||||||
|
python run_dreader.py ~/Books
|
||||||
|
python run_dreader.py tests/data/library-epub
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--width WIDTH Window width (default: 800)
|
||||||
|
--height HEIGHT Window height (default: 1200)
|
||||||
|
--fullscreen Run in fullscreen mode
|
||||||
|
--log-level LEVEL Logging level: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Run with default library path
|
||||||
|
python run_dreader.py
|
||||||
|
|
||||||
|
# Run with custom library
|
||||||
|
python run_dreader.py ~/Documents/Books
|
||||||
|
|
||||||
|
# Run in fullscreen
|
||||||
|
python run_dreader.py --fullscreen
|
||||||
|
|
||||||
|
# Run with debug logging
|
||||||
|
python run_dreader.py --log-level DEBUG
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from dreader.main import DReaderApplication, AppConfig
|
||||||
|
from dreader.hal_pygame import PygameDisplayHAL
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="DReader E-Book Reader Application",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s # Use default library path
|
||||||
|
%(prog)s ~/Books # Custom library path
|
||||||
|
%(prog)s --width 1200 --height 1600 # Custom window size
|
||||||
|
%(prog)s --fullscreen # Fullscreen mode
|
||||||
|
%(prog)s --log-level DEBUG # Debug logging
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'library_path',
|
||||||
|
nargs='?',
|
||||||
|
default=None,
|
||||||
|
help='Path to directory containing EPUB files (default: tests/data/library-epub)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--width',
|
||||||
|
type=int,
|
||||||
|
default=800,
|
||||||
|
help='Window width in pixels (default: 800)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--height',
|
||||||
|
type=int,
|
||||||
|
default=1200,
|
||||||
|
help='Window height in pixels (default: 1200)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--fullscreen',
|
||||||
|
action='store_true',
|
||||||
|
help='Run in fullscreen mode'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--library',
|
||||||
|
action='store_true',
|
||||||
|
help='Always start in library mode (ignore saved state)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--log-level',
|
||||||
|
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
|
||||||
|
default='INFO',
|
||||||
|
help='Logging level (default: INFO)'
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
log_level = getattr(logging, args.log_level)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=log_level,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("Starting DReader E-Book Reader")
|
||||||
|
|
||||||
|
# Determine library path
|
||||||
|
if args.library_path:
|
||||||
|
library_path = Path(args.library_path).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
# Default to test library
|
||||||
|
library_path = Path(__file__).parent / "tests" / "data" / "library-epub"
|
||||||
|
|
||||||
|
# Verify library path exists
|
||||||
|
if not library_path.exists():
|
||||||
|
logger.error(f"Library path does not exist: {library_path}")
|
||||||
|
print(f"\nError: Library directory not found: {library_path}")
|
||||||
|
print("\nPlease provide a valid path to a directory containing EPUB files.")
|
||||||
|
print("Example:")
|
||||||
|
print(f" python {sys.argv[0]} ~/Documents/Books")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not library_path.is_dir():
|
||||||
|
logger.error(f"Library path is not a directory: {library_path}")
|
||||||
|
print(f"\nError: Not a directory: {library_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info(f"Library path: {library_path}")
|
||||||
|
logger.info(f"Window size: {args.width}x{args.height}")
|
||||||
|
logger.info(f"Fullscreen: {args.fullscreen}")
|
||||||
|
logger.info(f"Force library mode: {args.library}")
|
||||||
|
|
||||||
|
# Create HAL
|
||||||
|
try:
|
||||||
|
hal = PygameDisplayHAL(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
fullscreen=args.fullscreen
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(f"Failed to initialize Pygame HAL: {e}")
|
||||||
|
print(f"\nError: {e}")
|
||||||
|
print("\nTo install Pygame, run:")
|
||||||
|
print(" pip install pygame")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create application config
|
||||||
|
config = AppConfig(
|
||||||
|
display_hal=hal,
|
||||||
|
library_path=str(library_path),
|
||||||
|
page_size=(args.width, args.height),
|
||||||
|
force_library_mode=args.library,
|
||||||
|
log_level=log_level
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create application
|
||||||
|
app = DReaderApplication(config)
|
||||||
|
|
||||||
|
# Run event loop
|
||||||
|
try:
|
||||||
|
logger.info("Starting event loop")
|
||||||
|
asyncio.run(hal.run_event_loop(app))
|
||||||
|
logger.info("Application exited normally")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Interrupted by user")
|
||||||
|
print("\nShutting down...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Application error: {e}", exc_info=True)
|
||||||
|
print(f"\nError: {e}")
|
||||||
|
print("\nFor more details, run with --log-level DEBUG")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
106
test_debug_overlay.py
Normal file
106
test_debug_overlay.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test the debug overlay visualization feature.
|
||||||
|
This creates an overlay with debug bounding boxes and saves it as an image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Enable debug mode
|
||||||
|
os.environ['DREADER_DEBUG_OVERLAY'] = '1'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_settings_overlay_debug():
|
||||||
|
"""Create settings overlay with debug visualization."""
|
||||||
|
print("="*70)
|
||||||
|
print("Creating Settings Overlay with Debug Bounding Boxes")
|
||||||
|
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 (with debug mode, this will draw bounding boxes)
|
||||||
|
overlay_image = settings_overlay.open(
|
||||||
|
base_page,
|
||||||
|
font_scale=1.0,
|
||||||
|
line_spacing=5,
|
||||||
|
inter_block_spacing=15,
|
||||||
|
word_spacing=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the result
|
||||||
|
output_path = Path(__file__).parent / "settings_overlay_debug.png"
|
||||||
|
overlay_image.save(output_path)
|
||||||
|
print(f"\nSettings overlay with debug boxes saved to: {output_path}")
|
||||||
|
print("Red boxes show clickable areas")
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_overlay_debug():
|
||||||
|
"""Create navigation overlay with debug visualization."""
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("Creating Navigation Overlay with Debug Bounding Boxes")
|
||||||
|
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 (with debug mode, this will draw bounding boxes)
|
||||||
|
overlay_image = nav_overlay.open(
|
||||||
|
base_page,
|
||||||
|
chapters=chapters,
|
||||||
|
bookmarks=[{"name": "Test Bookmark"}],
|
||||||
|
active_tab="contents"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the result
|
||||||
|
output_path = Path(__file__).parent / "navigation_overlay_debug.png"
|
||||||
|
overlay_image.save(output_path)
|
||||||
|
print(f"\nNavigation overlay with debug boxes saved to: {output_path}")
|
||||||
|
print("Red boxes show clickable areas")
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_settings_overlay_debug()
|
||||||
|
test_navigation_overlay_debug()
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("Debug visualizations created!")
|
||||||
|
print("Check the PNG files to see clickable areas marked with red boxes.")
|
||||||
|
print("="*70)
|
||||||
211
test_main_integration.py
Normal file
211
test_main_integration.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quick integration test for the main application controller.
|
||||||
|
|
||||||
|
This test verifies that all components integrate correctly without
|
||||||
|
requiring a GUI. It uses a mock HAL to simulate display/input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from dreader.main import DReaderApplication, AppConfig
|
||||||
|
from dreader.hal import DisplayHAL
|
||||||
|
from dreader.gesture import TouchEvent, GestureType
|
||||||
|
|
||||||
|
|
||||||
|
class MockDisplayHAL(DisplayHAL):
|
||||||
|
"""Mock HAL for headless testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.images_displayed = []
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
async def show_image(self, image: Image.Image):
|
||||||
|
"""Record displayed images."""
|
||||||
|
self.images_displayed.append(image)
|
||||||
|
print(f" [HAL] Displayed image: {image.size}")
|
||||||
|
|
||||||
|
async def get_touch_event(self):
|
||||||
|
"""Return queued events."""
|
||||||
|
if self.events:
|
||||||
|
return self.events.pop(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_brightness(self, level: int):
|
||||||
|
"""Mock brightness control."""
|
||||||
|
print(f" [HAL] Brightness set to {level}")
|
||||||
|
|
||||||
|
def queue_event(self, event: TouchEvent):
|
||||||
|
"""Add event to queue for testing."""
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_integration():
|
||||||
|
"""Test the complete integration."""
|
||||||
|
print("=" * 70)
|
||||||
|
print("DReader Main Application Integration Test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Find test library
|
||||||
|
library_path = Path(__file__).parent / "tests" / "data" / "library-epub"
|
||||||
|
|
||||||
|
if not library_path.exists():
|
||||||
|
print(f"\nError: Test library not found at {library_path}")
|
||||||
|
print("Please ensure test EPUB files exist in tests/data/library-epub/")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\nLibrary path: {library_path}")
|
||||||
|
|
||||||
|
# Create mock HAL
|
||||||
|
hal = MockDisplayHAL()
|
||||||
|
|
||||||
|
# Create config
|
||||||
|
config = AppConfig(
|
||||||
|
display_hal=hal,
|
||||||
|
library_path=str(library_path),
|
||||||
|
page_size=(800, 1200),
|
||||||
|
auto_save_interval=999 # Don't auto-save during test
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create application
|
||||||
|
app = DReaderApplication(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test 1: Start application (should show library)
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 1: Starting application")
|
||||||
|
print("-" * 70)
|
||||||
|
await app.start()
|
||||||
|
print(f"✓ Application started")
|
||||||
|
print(f" Current mode: {app.get_current_mode()}")
|
||||||
|
print(f" Images displayed: {len(hal.images_displayed)}")
|
||||||
|
|
||||||
|
assert len(hal.images_displayed) > 0, "No image displayed after start"
|
||||||
|
assert app.get_current_mode().value == "library", "Should start in library mode"
|
||||||
|
print("✓ Test 1 passed")
|
||||||
|
|
||||||
|
# Test 2: Simulate tap on first book
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 2: Selecting a book from library")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
# Tap in approximate location of first book
|
||||||
|
tap_event = TouchEvent(GestureType.TAP, 400, 150)
|
||||||
|
print(f" Simulating tap at (400, 150)")
|
||||||
|
|
||||||
|
images_before = len(hal.images_displayed)
|
||||||
|
await app.handle_touch(tap_event)
|
||||||
|
|
||||||
|
print(f" Current mode: {app.get_current_mode()}")
|
||||||
|
print(f" New images displayed: {len(hal.images_displayed) - images_before}")
|
||||||
|
|
||||||
|
# Should have transitioned to reading mode
|
||||||
|
if app.get_current_mode().value == "reading":
|
||||||
|
print("✓ Successfully entered reading mode")
|
||||||
|
print("✓ Test 2 passed")
|
||||||
|
else:
|
||||||
|
print("⚠ Tap may not have hit a book (this is OK for the test)")
|
||||||
|
print(" Manually entering reading mode for further tests...")
|
||||||
|
|
||||||
|
# Get first book from library and enter reading mode manually
|
||||||
|
if app.library:
|
||||||
|
books = app.library.scan_library()
|
||||||
|
if books:
|
||||||
|
await app._enter_reading_mode(books[0]['path'])
|
||||||
|
print(f"✓ Loaded book: {books[0]['title']}")
|
||||||
|
|
||||||
|
# Test 3: Page navigation
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 3: Page navigation")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
if app.get_current_mode().value == "reading":
|
||||||
|
images_before = len(hal.images_displayed)
|
||||||
|
|
||||||
|
# Next page (swipe left)
|
||||||
|
swipe_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600)
|
||||||
|
print(" Simulating swipe left (next page)")
|
||||||
|
await app.handle_touch(swipe_event)
|
||||||
|
|
||||||
|
print(f" New images displayed: {len(hal.images_displayed) - images_before}")
|
||||||
|
print("✓ Test 3 passed")
|
||||||
|
else:
|
||||||
|
print("⊘ Skipping (not in reading mode)")
|
||||||
|
|
||||||
|
# Test 4: Font size change (pinch gesture)
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 4: Font size adjustment")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
if app.get_current_mode().value == "reading":
|
||||||
|
images_before = len(hal.images_displayed)
|
||||||
|
|
||||||
|
# Increase font size
|
||||||
|
pinch_event = TouchEvent(GestureType.PINCH_OUT, 400, 600)
|
||||||
|
print(" Simulating pinch out (increase font)")
|
||||||
|
await app.handle_touch(pinch_event)
|
||||||
|
|
||||||
|
print(f" New images displayed: {len(hal.images_displayed) - images_before}")
|
||||||
|
print("✓ Test 4 passed")
|
||||||
|
else:
|
||||||
|
print("⊘ Skipping (not in reading mode)")
|
||||||
|
|
||||||
|
# Test 5: State persistence check
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 5: State persistence")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
state = app.state
|
||||||
|
print(f" Current mode: {state.mode}")
|
||||||
|
print(f" Current book: {state.current_book}")
|
||||||
|
print(f" Font scale: {state.settings.font_scale}")
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
success = app.state_manager.save_state(force=True)
|
||||||
|
if success:
|
||||||
|
print("✓ State saved successfully")
|
||||||
|
print("✓ Test 5 passed")
|
||||||
|
else:
|
||||||
|
print("✗ State save failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 6: Graceful shutdown
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 6: Graceful shutdown")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
await app.shutdown()
|
||||||
|
print("✓ Application shut down cleanly")
|
||||||
|
print("✓ Test 6 passed")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("✓ ALL TESTS PASSED")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"\nTotal images displayed: {len(hal.images_displayed)}")
|
||||||
|
print(f"Final mode: {app.get_current_mode()}")
|
||||||
|
print("\nIntegration test successful!")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ TEST FAILED: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
success = asyncio.run(test_integration())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
121
test_page_navigation.py
Normal file
121
test_page_navigation.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test page navigation (forward and backward).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from dreader.application import EbookReader
|
||||||
|
|
||||||
|
def test_navigation():
|
||||||
|
"""Test forward and backward page navigation."""
|
||||||
|
|
||||||
|
# Find a test book
|
||||||
|
epub_path = Path("tests/data/library-epub/pg11-images-3.epub")
|
||||||
|
|
||||||
|
if not epub_path.exists():
|
||||||
|
print(f"Error: Test EPUB not found at {epub_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("Page Navigation Test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Create reader
|
||||||
|
reader = EbookReader(page_size=(800, 1200))
|
||||||
|
|
||||||
|
# Load book
|
||||||
|
print(f"\nLoading: {epub_path}")
|
||||||
|
if not reader.load_epub(str(epub_path)):
|
||||||
|
print("Failed to load EPUB")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✓ Loaded: {reader.book_title}")
|
||||||
|
|
||||||
|
# Get starting position
|
||||||
|
start_pos = reader.get_position_info()
|
||||||
|
print(f"\nStarting position: {start_pos}")
|
||||||
|
|
||||||
|
# Test forward navigation
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 1: Forward navigation (next_page)")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
page = reader.next_page()
|
||||||
|
if page:
|
||||||
|
pos = reader.get_position_info()
|
||||||
|
print(f" Page {i+1}: position={pos['position']}, progress={reader.get_reading_progress()*100:.1f}%")
|
||||||
|
else:
|
||||||
|
print(f" Page {i+1}: Failed to advance")
|
||||||
|
return False
|
||||||
|
|
||||||
|
forward_pos = reader.get_position_info()
|
||||||
|
print(f"\nPosition after 3 forward: {forward_pos}")
|
||||||
|
|
||||||
|
# Test backward navigation
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 2: Backward navigation (previous_page)")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
page = reader.previous_page()
|
||||||
|
if page:
|
||||||
|
pos = reader.get_position_info()
|
||||||
|
print(f" Back {i+1}: position={pos['position']}, progress={reader.get_reading_progress()*100:.1f}%")
|
||||||
|
else:
|
||||||
|
print(f" Back {i+1}: Failed to go back (might be at start)")
|
||||||
|
|
||||||
|
final_pos = reader.get_position_info()
|
||||||
|
print(f"\nPosition after 3 backward: {final_pos}")
|
||||||
|
|
||||||
|
# Verify we're back at start
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 3: Verify position")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
if final_pos['position'] == start_pos['position']:
|
||||||
|
print("✓ Successfully returned to starting position")
|
||||||
|
print("✓ Backward navigation working correctly")
|
||||||
|
result = True
|
||||||
|
else:
|
||||||
|
print(f"✗ Position mismatch!")
|
||||||
|
print(f" Expected: {start_pos['position']}")
|
||||||
|
print(f" Got: {final_pos['position']}")
|
||||||
|
result = False
|
||||||
|
|
||||||
|
# Test going back from start (should return None or current page)
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print("Test 4: Try going back from first page")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
page = reader.previous_page()
|
||||||
|
if page:
|
||||||
|
pos = reader.get_position_info()
|
||||||
|
print(f" Still at position: {pos['position']}")
|
||||||
|
if pos['position'] == 0:
|
||||||
|
print("✓ Correctly stayed at first page")
|
||||||
|
else:
|
||||||
|
print("⚠ Position changed but shouldn't have")
|
||||||
|
else:
|
||||||
|
print(" previous_page() returned None (at start)")
|
||||||
|
print("✓ Correctly indicated at start of book")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
if result:
|
||||||
|
print("✓ ALL TESTS PASSED - Page navigation working correctly")
|
||||||
|
else:
|
||||||
|
print("✗ TESTS FAILED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = test_navigation()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
124
test_swipe_detection.py
Normal file
124
test_swipe_detection.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test swipe detection in Pygame HAL.
|
||||||
|
This will show you how to perform swipes and what gestures are detected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from dreader.hal_pygame import PygameDisplayHAL
|
||||||
|
from dreader.gesture import GestureType
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
async def test_swipes():
|
||||||
|
"""Test swipe detection with visual feedback."""
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("Swipe Detection Test")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nInstructions:")
|
||||||
|
print(" - Click and drag to create swipes")
|
||||||
|
print(" - Drag at least 30 pixels for swipe detection")
|
||||||
|
print(" - Short movements are detected as taps")
|
||||||
|
print(" - Press Q or ESC to quit")
|
||||||
|
print("\nSwipe directions:")
|
||||||
|
print(" - Drag LEFT → Next page (SWIPE_LEFT)")
|
||||||
|
print(" - Drag RIGHT → Previous page (SWIPE_RIGHT)")
|
||||||
|
print(" - Drag UP → Scroll up (SWIPE_UP)")
|
||||||
|
print(" - Drag DOWN → Scroll down (SWIPE_DOWN)")
|
||||||
|
print("\nOR use keyboard shortcuts:")
|
||||||
|
print(" - Left/Right Arrow or Space/PageUp/PageDown")
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
|
||||||
|
# Create HAL
|
||||||
|
hal = PygameDisplayHAL(width=800, height=600, title="Swipe Detection Test")
|
||||||
|
|
||||||
|
await hal.initialize()
|
||||||
|
|
||||||
|
# Create instruction image
|
||||||
|
img = Image.new('RGB', (800, 600), color=(240, 240, 240))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||||
|
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||||
|
except:
|
||||||
|
font_large = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Draw instructions
|
||||||
|
y = 50
|
||||||
|
draw.text((400, y), "Swipe Detection Test", fill=(0, 0, 0), font=font_large, anchor="mt")
|
||||||
|
|
||||||
|
y += 80
|
||||||
|
instructions = [
|
||||||
|
"Click and DRAG to create swipes:",
|
||||||
|
"",
|
||||||
|
"← Drag LEFT = Next Page",
|
||||||
|
"→ Drag RIGHT = Previous Page",
|
||||||
|
"↑ Drag UP = Scroll Up",
|
||||||
|
"↓ Drag DOWN = Scroll Down",
|
||||||
|
"",
|
||||||
|
"Minimum drag distance: 30 pixels",
|
||||||
|
"",
|
||||||
|
"Or use keyboard:",
|
||||||
|
"Space/Right Arrow = Next",
|
||||||
|
"Left Arrow = Previous",
|
||||||
|
"",
|
||||||
|
"Press Q or ESC to quit"
|
||||||
|
]
|
||||||
|
|
||||||
|
for line in instructions:
|
||||||
|
draw.text((400, y), line, fill=(0, 0, 0), font=font_small, anchor="mt")
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
await hal.show_image(img)
|
||||||
|
|
||||||
|
# Event loop
|
||||||
|
hal.running = True
|
||||||
|
gesture_count = 0
|
||||||
|
last_gesture = None
|
||||||
|
|
||||||
|
print("\nWaiting for gestures... (window is now open)")
|
||||||
|
|
||||||
|
while hal.running:
|
||||||
|
event = await hal.get_touch_event()
|
||||||
|
|
||||||
|
if event:
|
||||||
|
gesture_count += 1
|
||||||
|
last_gesture = event.gesture
|
||||||
|
|
||||||
|
print(f"\n[{gesture_count}] Detected: {event.gesture.value}")
|
||||||
|
print(f" Position: ({event.x}, {event.y})")
|
||||||
|
|
||||||
|
# Visual feedback
|
||||||
|
feedback_img = img.copy()
|
||||||
|
feedback_draw = ImageDraw.Draw(feedback_img)
|
||||||
|
|
||||||
|
# Draw gesture type
|
||||||
|
gesture_text = f"Gesture #{gesture_count}: {event.gesture.value.upper()}"
|
||||||
|
feedback_draw.rectangle([(0, 550), (800, 600)], fill=(50, 150, 50))
|
||||||
|
feedback_draw.text((400, 575), gesture_text, fill=(255, 255, 255), font=font_large, anchor="mm")
|
||||||
|
|
||||||
|
await hal.show_image(feedback_img)
|
||||||
|
|
||||||
|
# Brief pause to show feedback
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await hal.show_image(img)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
await hal.cleanup()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f"Test complete! Detected {gesture_count} gestures.")
|
||||||
|
if last_gesture:
|
||||||
|
print(f"Last gesture: {last_gesture.value}")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_swipes())
|
||||||
Loading…
x
Reference in New Issue
Block a user