Test appplication for offdevice testing
All checks were successful
Python CI / test (3.12) (push) Successful in 22m19s
Python CI / test (3.13) (push) Successful in 8m23s

This commit is contained in:
Duncan Tourolle 2025-11-09 17:47:34 +01:00
parent 678e1acf29
commit 01e79dfa4b
22 changed files with 3749 additions and 269 deletions

552
ARCHITECTURE.md Normal file
View 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

View File

@ -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
View 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
View 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
View 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

View File

@ -25,6 +25,8 @@ from dreader.state import (
)
from dreader.library import LibraryManager
from dreader.overlay import OverlayManager
from dreader.main import DReaderApplication, AppConfig
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
__version__ = "0.1.0"
__all__ = [
@ -56,4 +58,13 @@ __all__ = [
# Overlay
"OverlayManager",
# Main application
"DReaderApplication",
"AppConfig",
# HAL interfaces
"DisplayHAL",
"KeyboardInputHAL",
"EventLoopHAL",
]

View File

@ -47,7 +47,7 @@ from pyWebLayout.layout.ereader_layout import RenderingPosition
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.highlight import Highlight, HighlightColor
from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlight_from_query_result
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
from .state import OverlayState

188
dreader/hal.py Normal file
View 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
View 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()

View File

@ -234,28 +234,21 @@ class GestureRouter:
})
def _handle_swipe_up(self, y: int) -> GestureResponse:
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
# Check if swipe started from bottom 20% of screen
bottom_threshold = self.reader.page_size[1] * 0.8
if y >= bottom_threshold:
# Open TOC overlay
overlay_image = self.reader.open_toc_overlay()
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
# Open navigation overlay from anywhere on screen
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "toc",
"overlay_type": "navigation",
"active_tab": "contents",
"chapters": self.reader.get_chapters()
})
return GestureResponse(ActionType.NONE, {})
def _handle_swipe_down(self, y: int) -> GestureResponse:
"""Handle swipe down gesture - opens settings overlay if from top of screen"""
# Check if swipe started from top 20% of screen
top_threshold = self.reader.page_size[1] * 0.2
if y <= top_threshold:
# Open settings overlay
"""Handle swipe down gesture - opens Settings overlay"""
# Open settings overlay from anywhere on screen
overlay_image = self.reader.open_settings_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {

View File

@ -240,47 +240,47 @@ def generate_settings_overlay(
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
<b>Font Size: {font_percent}%</b>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
<b>Line Spacing: {line_spacing}px</b>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
<b>Paragraph Spacing: {inter_block_spacing}px</b>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
<b>Word Spacing: {word_spacing}px</b>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000;">Decrease [ - ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000;">Increase [ + ]</a>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
</div>
<div style="margin: 20px 0;">
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;"> Back to Library</a>
<p style="margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px; display: block; padding: 15px;"> Back to Library</a>
</p>
</div>
@ -538,9 +538,8 @@ def generate_navigation_overlay(
link_text = f'{i+1}. {title} ' # Extra spaces for padding
chapter_items.append(
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
f'border-left: 3px solid #000;">'
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
f'{link_text}</a></p>'
)
@ -551,9 +550,8 @@ def generate_navigation_overlay(
position_text = bookmark.get('position', 'Saved position')
bookmark_items.append(
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
f'border-left: 3px solid #000;">'
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block;">'
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
f'<span style="font-weight: bold; display: block;">{name}</span>'
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
f'</a></p>'

428
dreader/main.py Normal file
View 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

View File

@ -168,6 +168,7 @@ class OverlaySubApplication(ABC):
Composited PIL Image with popup effect
"""
from PIL import ImageDraw, ImageEnhance
import os
# Convert base image to RGB
result = base_page.convert('RGB').copy()
@ -180,6 +181,11 @@ class OverlaySubApplication(ABC):
if overlay_panel.mode != 'RGB':
overlay_panel = overlay_panel.convert('RGB')
# DEBUG: Draw bounding boxes on interactive elements if debug mode enabled
debug_mode = os.environ.get('DREADER_DEBUG_OVERLAY', '0') == '1'
if debug_mode:
overlay_panel = self._draw_debug_bounding_boxes(overlay_panel.copy())
# Calculate centered position for the panel
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
@ -245,6 +251,13 @@ class OverlaySubApplication(ABC):
# Query the point
result = current_page.query_point((overlay_x, overlay_y))
import logging
logger = logging.getLogger(__name__)
logger.info(f"[OVERLAY_BASE] query_point({overlay_x}, {overlay_y}) returned: {result}")
if result:
logger.info(f"[OVERLAY_BASE] text={result.text}, link_target={result.link_target}, is_interactive={result.is_interactive}")
logger.info(f"[OVERLAY_BASE] bounds={result.bounds}, object_type={result.object_type}")
if not result:
return None
@ -271,3 +284,76 @@ class OverlaySubApplication(ABC):
panel_width = int(self.page_size[0] * width_ratio)
panel_height = int(self.page_size[1] * height_ratio)
return (panel_width, panel_height)
def _draw_debug_bounding_boxes(self, overlay_panel: Image.Image) -> Image.Image:
"""
Draw bounding boxes around all interactive elements for debugging.
This scans the overlay panel and draws red rectangles around all
clickable elements to help visualize where users need to click.
Args:
overlay_panel: Overlay panel image to annotate
Returns:
Annotated overlay panel with bounding boxes
"""
from PIL import ImageDraw, ImageFont
import logging
logger = logging.getLogger(__name__)
if not self._overlay_reader or not self._overlay_reader.manager:
logger.warning("[DEBUG] No overlay reader available for debug visualization")
return overlay_panel
page = self._overlay_reader.manager.get_current_page()
if not page:
logger.warning("[DEBUG] No page available for debug visualization")
return overlay_panel
# Scan for all interactive elements
panel_width, panel_height = overlay_panel.size
link_regions = {} # link_target -> (min_x, min_y, max_x, max_y)
logger.info(f"[DEBUG] Scanning {panel_width}x{panel_height} overlay for interactive elements...")
# Scan with fine granularity to find all interactive pixels
for y in range(0, panel_height, 2):
for x in range(0, panel_width, 2):
result = page.query_point((x, y))
if result and result.link_target:
if result.link_target not in link_regions:
link_regions[result.link_target] = [x, y, x, y]
else:
# Expand bounding box
link_regions[result.link_target][0] = min(link_regions[result.link_target][0], x)
link_regions[result.link_target][1] = min(link_regions[result.link_target][1], y)
link_regions[result.link_target][2] = max(link_regions[result.link_target][2], x)
link_regions[result.link_target][3] = max(link_regions[result.link_target][3], y)
# Draw bounding boxes
draw = ImageDraw.Draw(overlay_panel)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except:
font = ImageFont.load_default()
logger.info(f"[DEBUG] Found {len(link_regions)} interactive regions")
for link_target, (min_x, min_y, max_x, max_y) in link_regions.items():
# Draw red bounding box
draw.rectangle(
[min_x, min_y, max_x, max_y],
outline=(255, 0, 0),
width=2
)
# Draw label
label = link_target[:20] # Truncate if too long
draw.text((min_x + 2, min_y - 12), label, fill=(255, 0, 0), font=font)
logger.info(f"[DEBUG] {link_target}: ({min_x}, {min_y}) to ({max_x}, {max_y})")
return overlay_panel

View File

@ -107,16 +107,25 @@ class NavigationOverlay(OverlaySubApplication):
Returns:
GestureResponse with appropriate action
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[NAV_OVERLAY] Handling tap at ({x}, {y})")
logger.info(f"[NAV_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
logger.info(f"[NAV_OVERLAY] Query result: {query_result}")
# If query failed (tap outside overlay panel), close it
if query_result is None:
logger.info(f"[NAV_OVERLAY] Tap outside overlay panel, closing")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a link
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
logger.info(f"[NAV_OVERLAY] Found interactive link: {link_target}")
# Parse "tab:tabname" format for tab switching
if link_target.startswith("tab:"):
@ -168,10 +177,12 @@ class NavigationOverlay(OverlaySubApplication):
elif link_target.startswith("action:"):
action = link_target.split(":", 1)[1]
if action == "close":
logger.info(f"[NAV_OVERLAY] Close button clicked")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Not an interactive element, close overlay
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Tap inside overlay but not on interactive element - keep overlay open
logger.info(f"[NAV_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
return GestureResponse(ActionType.NONE, {})
def switch_tab(self, new_tab: str) -> Optional[Image.Image]:
"""

View File

@ -90,20 +90,30 @@ class SettingsOverlay(OverlaySubApplication):
Returns:
GestureResponse with appropriate action
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[SETTINGS_OVERLAY] Handling tap at ({x}, {y})")
logger.info(f"[SETTINGS_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
logger.info(f"[SETTINGS_OVERLAY] Query result: {query_result}")
# If query failed (tap outside overlay panel), close it
if query_result is None:
logger.info(f"[SETTINGS_OVERLAY] Tap outside overlay panel, closing")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a settings control link
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
logger.info(f"[SETTINGS_OVERLAY] Found interactive link: {link_target}")
# Parse "setting:action" format
if link_target.startswith("setting:"):
action = link_target.split(":", 1)[1]
logger.info(f"[SETTINGS_OVERLAY] Applying setting change: {action}")
return self._apply_setting_change(action)
# Parse "action:command" format for other actions
@ -111,10 +121,12 @@ class SettingsOverlay(OverlaySubApplication):
action = link_target.split(":", 1)[1]
if action == "back_to_library":
logger.info(f"[SETTINGS_OVERLAY] Back to library clicked")
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
# Not a setting control, close overlay
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Tap inside overlay but not on interactive element - keep overlay open
logger.info(f"[SETTINGS_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
return GestureResponse(ActionType.NONE, {})
def refresh(self, updated_base_page: Image.Image,
font_scale: float,

188
run_dreader.py Executable file
View 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
View 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
View 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
View 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
View 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())