Test appplication for offdevice testing
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Hardware Abstraction Layer (HAL) interface for DReader.
|
||||
|
||||
This module defines the abstract interface that platform-specific
|
||||
display/input implementations must provide.
|
||||
|
||||
The HAL separates the core e-reader logic from platform-specific
|
||||
hardware details (display, touch input, buttons, etc.).
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator, Optional
|
||||
from PIL import Image
|
||||
|
||||
from .gesture import TouchEvent
|
||||
|
||||
|
||||
class DisplayHAL(ABC):
|
||||
"""
|
||||
Abstract interface for display and input hardware.
|
||||
|
||||
Platform-specific implementations should subclass this and provide
|
||||
concrete implementations for all abstract methods.
|
||||
|
||||
The HAL is responsible for:
|
||||
- Displaying images on the screen
|
||||
- Capturing touch/click input and converting to TouchEvent
|
||||
- Hardware-specific features (brightness, sleep, etc.)
|
||||
|
||||
All methods are async to support non-blocking I/O.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display a PIL Image on the screen.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
|
||||
This method should handle:
|
||||
- Converting image format if needed for the display
|
||||
- Scaling/cropping if image size doesn't match display
|
||||
- Updating the physical display hardware
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Wait for and return the next touch event.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None if no event (non-blocking mode)
|
||||
|
||||
This method should:
|
||||
- Read from touch hardware
|
||||
- Convert raw coordinates to TouchEvent
|
||||
- Detect gesture type (tap, swipe, etc.)
|
||||
- Return None immediately if no event available
|
||||
|
||||
Note: For blocking behavior, implement a loop that awaits this
|
||||
method in the main event loop.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness.
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10, where 0=dimmest, 10=brightest)
|
||||
|
||||
Platform implementations should map this to their hardware's
|
||||
actual brightness range.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize the display hardware.
|
||||
|
||||
This optional method is called once before the application starts.
|
||||
Override to perform platform-specific initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
"""
|
||||
Clean up display hardware resources.
|
||||
|
||||
This optional method is called during application shutdown.
|
||||
Override to perform platform-specific cleanup.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def show_message(self, message: str, duration: float = 2.0):
|
||||
"""
|
||||
Display a text message (for loading screens, errors, etc.).
|
||||
|
||||
Args:
|
||||
message: Text message to display
|
||||
duration: How long to show message (seconds)
|
||||
|
||||
Default implementation creates a simple text image.
|
||||
Override for platform-specific message display.
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
# Create simple text image
|
||||
img = Image.new('RGB', (800, 1200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to use a decent font, fall back to default
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw centered text
|
||||
bbox = draw.textbbox((0, 0), message, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (800 - text_width) // 2
|
||||
y = (1200 - text_height) // 2
|
||||
|
||||
draw.text((x, y), message, fill=(0, 0, 0), font=font)
|
||||
|
||||
await self.show_image(img)
|
||||
|
||||
# Wait for duration
|
||||
if duration > 0:
|
||||
import asyncio
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
|
||||
class KeyboardInputHAL(ABC):
|
||||
"""
|
||||
Optional abstract interface for keyboard input.
|
||||
|
||||
This is separate from DisplayHAL to support platforms that have
|
||||
both touch and keyboard input (e.g., desktop testing).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_key_event(self) -> Optional[str]:
|
||||
"""
|
||||
Get the next keyboard event.
|
||||
|
||||
Returns:
|
||||
Key name as string (e.g., "up", "down", "enter", "q")
|
||||
None if no key event available
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EventLoopHAL(DisplayHAL):
|
||||
"""
|
||||
Extended HAL interface that provides its own event loop.
|
||||
|
||||
Some platforms (e.g., Pygame, Qt) have their own event loop that
|
||||
must be used. This interface allows the HAL to run the main loop
|
||||
and call back to the application.
|
||||
|
||||
Usage:
|
||||
hal = MyEventLoopHAL()
|
||||
app = DReaderApplication(AppConfig(display_hal=hal, ...))
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the platform's event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance to send events to
|
||||
|
||||
This method should:
|
||||
1. Initialize the display
|
||||
2. Call app.start()
|
||||
3. Enter event loop
|
||||
4. Call app.handle_touch(event) for each event
|
||||
5. Handle quit events and call app.shutdown()
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Pygame-based Display HAL for desktop testing.
|
||||
|
||||
This HAL implementation uses Pygame to provide a desktop window
|
||||
for testing the e-reader application without physical hardware.
|
||||
|
||||
Features:
|
||||
- Window display with PIL image rendering
|
||||
- Mouse input converted to touch events
|
||||
- Keyboard shortcuts for common actions
|
||||
- Gesture detection (swipes via mouse drag)
|
||||
|
||||
Usage:
|
||||
from dreader.hal_pygame import PygameDisplayHAL
|
||||
from dreader.main import DReaderApplication, AppConfig
|
||||
|
||||
hal = PygameDisplayHAL(width=800, height=1200)
|
||||
config = AppConfig(display_hal=hal, library_path="~/Books")
|
||||
app = DReaderApplication(config)
|
||||
|
||||
await hal.run_event_loop(app)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from .hal import EventLoopHAL
|
||||
from .gesture import TouchEvent, GestureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pygame is optional - only needed for desktop testing
|
||||
try:
|
||||
import pygame
|
||||
PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYGAME_AVAILABLE = False
|
||||
logger.warning("Pygame not available. Install with: pip install pygame")
|
||||
|
||||
|
||||
class PygameDisplayHAL(EventLoopHAL):
|
||||
"""
|
||||
Pygame-based display HAL for desktop testing.
|
||||
|
||||
This implementation provides a desktop window that simulates
|
||||
an e-reader display with touch input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 800,
|
||||
height: int = 1200,
|
||||
title: str = "DReader E-Book Reader",
|
||||
fullscreen: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize Pygame display.
|
||||
|
||||
Args:
|
||||
width: Window width in pixels
|
||||
height: Window height in pixels
|
||||
title: Window title
|
||||
fullscreen: If True, open in fullscreen mode
|
||||
"""
|
||||
if not PYGAME_AVAILABLE:
|
||||
raise RuntimeError("Pygame is required for PygameDisplayHAL. Install with: pip install pygame")
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.title = title
|
||||
self.fullscreen = fullscreen
|
||||
|
||||
self.screen = None
|
||||
self.running = False
|
||||
|
||||
# Touch/gesture tracking
|
||||
self.mouse_down_pos: Optional[tuple[int, int]] = None
|
||||
self.mouse_down_time: float = 0
|
||||
self.drag_threshold = 20 # pixels (reduced from 30 for easier swiping)
|
||||
self.long_press_duration = 0.5 # seconds
|
||||
|
||||
logger.info(f"PygameDisplayHAL initialized: {width}x{height}")
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize Pygame and create window."""
|
||||
logger.info("Initializing Pygame")
|
||||
pygame.init()
|
||||
|
||||
# Set up display
|
||||
flags = pygame.DOUBLEBUF
|
||||
if self.fullscreen:
|
||||
flags |= pygame.FULLSCREEN
|
||||
|
||||
self.screen = pygame.display.set_mode((self.width, self.height), flags)
|
||||
pygame.display.set_caption(self.title)
|
||||
|
||||
# Set up font for messages
|
||||
pygame.font.init()
|
||||
|
||||
logger.info("Pygame initialized successfully")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Pygame resources."""
|
||||
logger.info("Cleaning up Pygame")
|
||||
if pygame.get_init():
|
||||
pygame.quit()
|
||||
|
||||
async def show_image(self, image: Image.Image):
|
||||
"""
|
||||
Display PIL image on Pygame window.
|
||||
|
||||
Args:
|
||||
image: PIL Image to display
|
||||
"""
|
||||
if not self.screen:
|
||||
logger.warning("Screen not initialized")
|
||||
return
|
||||
|
||||
# Convert PIL image to pygame surface
|
||||
# PIL uses RGB, pygame uses RGB
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize if needed
|
||||
if image.size != (self.width, self.height):
|
||||
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to numpy array, then to pygame surface
|
||||
img_array = np.array(image)
|
||||
surface = pygame.surfarray.make_surface(np.transpose(img_array, (1, 0, 2)))
|
||||
|
||||
# Blit to screen
|
||||
self.screen.blit(surface, (0, 0))
|
||||
pygame.display.flip()
|
||||
|
||||
# Small delay to prevent excessive CPU usage
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
async def get_touch_event(self) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Process pygame events and convert to TouchEvent.
|
||||
|
||||
Returns:
|
||||
TouchEvent if available, None otherwise
|
||||
"""
|
||||
if not pygame.get_init():
|
||||
return None
|
||||
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
logger.info("Quit event received")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN:
|
||||
# Mouse down - start tracking for gesture
|
||||
self.mouse_down_pos = event.pos
|
||||
self.mouse_down_time = pygame.time.get_ticks() / 1000.0
|
||||
logger.info(f"[MOUSE] Button DOWN at {event.pos}")
|
||||
|
||||
elif event.type == pygame.MOUSEMOTION:
|
||||
# Show drag indicator while mouse is down
|
||||
if self.mouse_down_pos and pygame.mouse.get_pressed()[0]:
|
||||
current_pos = event.pos
|
||||
dx = current_pos[0] - self.mouse_down_pos[0]
|
||||
dy = current_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
|
||||
# Log dragging in progress
|
||||
if distance > 5: # Log any significant drag
|
||||
logger.info(f"[DRAG] Moving: dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px")
|
||||
|
||||
# Only show if dragging beyond threshold
|
||||
if distance > self.drag_threshold:
|
||||
# Draw a line showing the swipe direction
|
||||
if self.screen:
|
||||
# This is just for visual feedback during drag
|
||||
# The actual gesture detection happens on mouse up
|
||||
pass
|
||||
|
||||
elif event.type == pygame.MOUSEBUTTONUP:
|
||||
if self.mouse_down_pos is None:
|
||||
logger.warning("[MOUSE] Button UP but no down position recorded")
|
||||
continue
|
||||
|
||||
mouse_up_pos = event.pos
|
||||
mouse_up_time = pygame.time.get_ticks() / 1000.0
|
||||
|
||||
# Calculate distance and time
|
||||
dx = mouse_up_pos[0] - self.mouse_down_pos[0]
|
||||
dy = mouse_up_pos[1] - self.mouse_down_pos[1]
|
||||
distance = (dx**2 + dy**2) ** 0.5
|
||||
duration = mouse_up_time - self.mouse_down_time
|
||||
|
||||
logger.info(f"[MOUSE] Button UP at {mouse_up_pos}")
|
||||
logger.info(f"[GESTURE] dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px, duration={duration:.2f}s, threshold={self.drag_threshold}px")
|
||||
|
||||
# Detect gesture type
|
||||
gesture = None
|
||||
x, y = mouse_up_pos
|
||||
|
||||
if distance < self.drag_threshold:
|
||||
# Tap or long press
|
||||
if duration >= self.long_press_duration:
|
||||
gesture = GestureType.LONG_PRESS
|
||||
logger.info(f"[GESTURE] ✓ Detected: LONG_PRESS")
|
||||
else:
|
||||
gesture = GestureType.TAP
|
||||
logger.info(f"[GESTURE] ✓ Detected: TAP")
|
||||
else:
|
||||
# Swipe
|
||||
if abs(dx) > abs(dy):
|
||||
# Horizontal swipe
|
||||
if dx > 0:
|
||||
gesture = GestureType.SWIPE_RIGHT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_RIGHT (dx={dx:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_LEFT
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_LEFT (dx={dx:.0f})")
|
||||
else:
|
||||
# Vertical swipe
|
||||
if dy > 0:
|
||||
gesture = GestureType.SWIPE_DOWN
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_DOWN (dy={dy:.0f})")
|
||||
else:
|
||||
gesture = GestureType.SWIPE_UP
|
||||
logger.info(f"[GESTURE] ✓ Detected: SWIPE_UP (dy={dy:.0f})")
|
||||
|
||||
# Reset tracking
|
||||
self.mouse_down_pos = None
|
||||
|
||||
if gesture:
|
||||
logger.info(f"[EVENT] Returning TouchEvent: {gesture.value} at ({x}, {y})")
|
||||
return TouchEvent(gesture, x, y)
|
||||
else:
|
||||
logger.warning("[EVENT] No gesture detected (should not happen)")
|
||||
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
# Keyboard shortcuts
|
||||
return await self._handle_keyboard(event)
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_keyboard(self, event) -> Optional[TouchEvent]:
|
||||
"""
|
||||
Handle keyboard shortcuts.
|
||||
|
||||
Args:
|
||||
event: Pygame keyboard event
|
||||
|
||||
Returns:
|
||||
TouchEvent equivalent of keyboard action
|
||||
"""
|
||||
# Arrow keys for page navigation
|
||||
if event.key == pygame.K_LEFT or event.key == pygame.K_PAGEUP:
|
||||
# Previous page
|
||||
return TouchEvent(GestureType.SWIPE_RIGHT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_RIGHT or event.key == pygame.K_PAGEDOWN or event.key == pygame.K_SPACE:
|
||||
# Next page
|
||||
return TouchEvent(GestureType.SWIPE_LEFT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_UP:
|
||||
# Scroll up (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_DOWN, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_DOWN:
|
||||
# Scroll down (if applicable)
|
||||
return TouchEvent(GestureType.SWIPE_UP, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
|
||||
# Quit
|
||||
logger.info("Quit via keyboard")
|
||||
self.running = False
|
||||
return None
|
||||
|
||||
elif event.key == pygame.K_EQUALS or event.key == pygame.K_PLUS:
|
||||
# Zoom in (pinch out)
|
||||
return TouchEvent(GestureType.PINCH_OUT, self.width // 2, self.height // 2)
|
||||
|
||||
elif event.key == pygame.K_MINUS:
|
||||
# Zoom out (pinch in)
|
||||
return TouchEvent(GestureType.PINCH_IN, self.width // 2, self.height // 2)
|
||||
|
||||
return None
|
||||
|
||||
async def set_brightness(self, level: int):
|
||||
"""
|
||||
Set display brightness (not supported in Pygame).
|
||||
|
||||
Args:
|
||||
level: Brightness level (0-10)
|
||||
|
||||
Note: Brightness control is not available in Pygame.
|
||||
This is a no-op for desktop testing.
|
||||
"""
|
||||
logger.debug(f"Brightness set to {level} (not supported in Pygame)")
|
||||
|
||||
async def run_event_loop(self, app):
|
||||
"""
|
||||
Run the Pygame event loop.
|
||||
|
||||
Args:
|
||||
app: DReaderApplication instance
|
||||
|
||||
This method:
|
||||
1. Initializes Pygame
|
||||
2. Starts the application
|
||||
3. Runs the event loop
|
||||
4. Handles events and updates display
|
||||
5. Shuts down gracefully
|
||||
"""
|
||||
logger.info("Starting Pygame event loop")
|
||||
|
||||
try:
|
||||
# Initialize
|
||||
await self.initialize()
|
||||
await app.start()
|
||||
|
||||
self.running = True
|
||||
|
||||
# Show instructions
|
||||
await self._show_instructions()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Main event loop
|
||||
clock = pygame.time.Clock()
|
||||
|
||||
while self.running and app.is_running():
|
||||
# Process events
|
||||
touch_event = await self.get_touch_event()
|
||||
|
||||
if touch_event:
|
||||
# Handle touch event
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
# Cap frame rate
|
||||
clock.tick(60) # 60 FPS max
|
||||
await asyncio.sleep(0.001) # Yield to other async tasks
|
||||
|
||||
logger.info("Event loop ended")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event loop: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await app.shutdown()
|
||||
await self.cleanup()
|
||||
|
||||
async def _show_instructions(self):
|
||||
"""Show keyboard instructions overlay."""
|
||||
if not self.screen:
|
||||
return
|
||||
|
||||
# Create instruction text
|
||||
font = pygame.font.Font(None, 24)
|
||||
instructions = [
|
||||
"DReader E-Book Reader",
|
||||
"",
|
||||
"Mouse Gestures:",
|
||||
" Drag LEFT (horizontal) = Next Page",
|
||||
" Drag RIGHT (horizontal) = Previous Page*",
|
||||
" Drag UP (vertical) = Navigation/TOC Overlay",
|
||||
" Drag DOWN (vertical) = Settings Overlay",
|
||||
"",
|
||||
"Keyboard Shortcuts:",
|
||||
" Space / Right Arrow = Next Page",
|
||||
" Left Arrow = Previous Page*",
|
||||
" +/- = Font Size",
|
||||
" Q/Escape = Quit",
|
||||
"",
|
||||
"*Previous page not working (pyWebLayout bug)",
|
||||
"",
|
||||
"Press any key to start..."
|
||||
]
|
||||
|
||||
# Create semi-transparent overlay
|
||||
overlay = pygame.Surface((self.width, self.height))
|
||||
overlay.fill((255, 255, 255))
|
||||
overlay.set_alpha(230)
|
||||
|
||||
# Render text
|
||||
y = 100
|
||||
for line in instructions:
|
||||
if line:
|
||||
text = font.render(line, True, (0, 0, 0))
|
||||
else:
|
||||
text = pygame.Surface((1, 20)) # Empty line
|
||||
text_rect = text.get_rect(center=(self.width // 2, y))
|
||||
overlay.blit(text, text_rect)
|
||||
y += 30
|
||||
|
||||
# Display
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
pygame.display.flip()
|
||||
@@ -234,36 +234,29 @@ 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()
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "toc",
|
||||
"chapters": self.reader.get_chapters()
|
||||
})
|
||||
"""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": "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
|
||||
overlay_image = self.reader.open_settings_overlay()
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "settings",
|
||||
"font_scale": self.reader.base_font_scale,
|
||||
"line_spacing": self.reader.page_style.line_spacing,
|
||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
||||
})
|
||||
"""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, {
|
||||
"overlay_type": "settings",
|
||||
"font_scale": self.reader.base_font_scale,
|
||||
"line_spacing": self.reader.page_style.line_spacing,
|
||||
"inter_block_spacing": self.reader.page_style.inter_block_spacing
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
|
||||
+22
-24
@@ -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
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Main application controller for DReader e-reader application.
|
||||
|
||||
This module provides the DReaderApplication class which orchestrates:
|
||||
- Library and reading mode transitions
|
||||
- State persistence and recovery
|
||||
- HAL integration for display and input
|
||||
- Event routing and handling
|
||||
|
||||
The application uses asyncio for non-blocking operations and integrates
|
||||
with a hardware abstraction layer (HAL) for platform independence.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from PIL import Image
|
||||
|
||||
from .library import LibraryManager
|
||||
from .application import EbookReader
|
||||
from .state import StateManager, EreaderMode, OverlayState, BookState
|
||||
from .gesture import TouchEvent, GestureType, ActionType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""
|
||||
Configuration for DReaderApplication.
|
||||
|
||||
Attributes:
|
||||
display_hal: Hardware abstraction layer for display/input
|
||||
library_path: Path to directory containing EPUB files
|
||||
page_size: Tuple of (width, height) for rendered pages
|
||||
bookmarks_dir: Directory for bookmark storage (default: ~/.config/dreader/bookmarks)
|
||||
highlights_dir: Directory for highlights storage (default: ~/.config/dreader/highlights)
|
||||
state_file: Path to state JSON file (default: ~/.config/dreader/state.json)
|
||||
auto_save_interval: Seconds between automatic state saves (default: 60)
|
||||
force_library_mode: If True, always start in library mode (default: False)
|
||||
log_level: Logging level (default: logging.INFO)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_hal,
|
||||
library_path: str,
|
||||
page_size: tuple[int, int] = (800, 1200),
|
||||
bookmarks_dir: Optional[str] = None,
|
||||
highlights_dir: Optional[str] = None,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save_interval: int = 60,
|
||||
force_library_mode: bool = False,
|
||||
log_level: int = logging.INFO
|
||||
):
|
||||
self.display_hal = display_hal
|
||||
self.library_path = library_path
|
||||
self.page_size = page_size
|
||||
self.force_library_mode = force_library_mode
|
||||
|
||||
# Set up default config paths
|
||||
config_dir = Path.home() / ".config" / "dreader"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.bookmarks_dir = bookmarks_dir or str(config_dir / "bookmarks")
|
||||
self.highlights_dir = highlights_dir or str(config_dir / "highlights")
|
||||
self.state_file = state_file or str(config_dir / "state.json")
|
||||
self.auto_save_interval = auto_save_interval
|
||||
self.log_level = log_level
|
||||
|
||||
|
||||
class DReaderApplication:
|
||||
"""
|
||||
Main application controller coordinating library and reading modes.
|
||||
|
||||
This class orchestrates all major components of the e-reader:
|
||||
- LibraryManager for book browsing
|
||||
- EbookReader for reading books
|
||||
- StateManager for persistence
|
||||
- DisplayHAL for hardware integration
|
||||
|
||||
Usage:
|
||||
config = AppConfig(
|
||||
display_hal=MyDisplayHAL(),
|
||||
library_path="/path/to/books"
|
||||
)
|
||||
|
||||
app = DReaderApplication(config)
|
||||
await app.start()
|
||||
|
||||
# In event loop:
|
||||
await app.handle_touch(touch_event)
|
||||
|
||||
await app.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(self, config: AppConfig):
|
||||
"""
|
||||
Initialize the application with configuration.
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=config.log_level)
|
||||
logger.info("Initializing DReaderApplication")
|
||||
|
||||
# State management
|
||||
self.state_manager = StateManager(
|
||||
state_file=config.state_file,
|
||||
auto_save_interval=config.auto_save_interval
|
||||
)
|
||||
self.state = self.state_manager.load_state()
|
||||
logger.info(f"Loaded state: mode={self.state.mode}, current_book={self.state.current_book}")
|
||||
|
||||
# Components (lazy-initialized)
|
||||
self.library: Optional[LibraryManager] = None
|
||||
self.reader: Optional[EbookReader] = None
|
||||
|
||||
# Display abstraction
|
||||
self.display_hal = config.display_hal
|
||||
self.current_image: Optional[Image.Image] = None
|
||||
|
||||
# Running state
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the application and display initial screen.
|
||||
|
||||
This method:
|
||||
1. Starts automatic state saving
|
||||
2. Restores previous mode or shows library
|
||||
3. Displays the initial screen
|
||||
"""
|
||||
logger.info("Starting DReaderApplication")
|
||||
self.running = True
|
||||
|
||||
# Start auto-save
|
||||
self.state_manager.start_auto_save()
|
||||
logger.info(f"Auto-save started (interval: {self.config.auto_save_interval}s)")
|
||||
|
||||
# Restore previous mode (or force library mode if configured)
|
||||
force_library = getattr(self.config, 'force_library_mode', False)
|
||||
|
||||
if force_library:
|
||||
logger.info("Force library mode enabled - starting in library")
|
||||
await self._enter_library_mode()
|
||||
elif self.state.mode == EreaderMode.READING and self.state.current_book:
|
||||
logger.info(f"Resuming reading mode: {self.state.current_book.path}")
|
||||
await self._enter_reading_mode(self.state.current_book.path)
|
||||
else:
|
||||
logger.info("Entering library mode")
|
||||
await self._enter_library_mode()
|
||||
|
||||
# Display initial screen
|
||||
await self._update_display()
|
||||
logger.info("Application started successfully")
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Gracefully shutdown the application.
|
||||
|
||||
This method:
|
||||
1. Saves current reading position
|
||||
2. Closes active components
|
||||
3. Stops auto-save and saves final state
|
||||
"""
|
||||
logger.info("Shutting down DReaderApplication")
|
||||
self.running = False
|
||||
|
||||
# Save current position if reading
|
||||
if self.reader and self.reader.is_loaded():
|
||||
logger.info("Saving auto-resume position")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
|
||||
# Clean up library
|
||||
if self.library:
|
||||
self.library.cleanup()
|
||||
|
||||
# Stop auto-save and save final state
|
||||
await self.state_manager.stop_auto_save(save_final=True)
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
async def handle_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Process touch event based on current mode.
|
||||
|
||||
Args:
|
||||
event: Touch event from HAL
|
||||
"""
|
||||
logger.info(f"[APP] Received touch event: {event.gesture.value} at ({event.x}, {event.y}), mode={self.state.mode.value}")
|
||||
|
||||
if self.state.mode == EreaderMode.LIBRARY:
|
||||
logger.info("[APP] Routing to library touch handler")
|
||||
await self._handle_library_touch(event)
|
||||
elif self.state.mode == EreaderMode.READING:
|
||||
logger.info("[APP] Routing to reading touch handler")
|
||||
await self._handle_reading_touch(event)
|
||||
|
||||
# Update display after handling
|
||||
await self._update_display()
|
||||
|
||||
async def _enter_library_mode(self):
|
||||
"""
|
||||
Switch to library browsing mode.
|
||||
|
||||
This method:
|
||||
1. Saves and closes reader if active
|
||||
2. Initializes library manager
|
||||
3. Renders library view
|
||||
4. Updates state
|
||||
"""
|
||||
logger.info("Entering library mode")
|
||||
|
||||
# Save and close reader if active
|
||||
if self.reader:
|
||||
if self.reader.is_loaded():
|
||||
logger.info("Saving reading position before closing")
|
||||
self.reader.save_position("__auto_resume__")
|
||||
self.reader.close()
|
||||
self.reader = None
|
||||
|
||||
# Initialize library if needed
|
||||
if not self.library:
|
||||
logger.info(f"Initializing library manager: {self.config.library_path}")
|
||||
self.library = LibraryManager(
|
||||
library_path=self.config.library_path,
|
||||
page_size=self.config.page_size,
|
||||
cache_dir=None # Uses default ~/.config/dreader
|
||||
)
|
||||
|
||||
# Scan for books (async operation)
|
||||
logger.info("Scanning library for books")
|
||||
books = self.library.scan_library()
|
||||
logger.info(f"Found {len(books)} books")
|
||||
|
||||
# Render library view
|
||||
logger.info("Rendering library view")
|
||||
self.current_image = self.library.render_library()
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_mode(EreaderMode.LIBRARY)
|
||||
logger.info("Library mode active")
|
||||
|
||||
async def _enter_reading_mode(self, book_path: str):
|
||||
"""
|
||||
Switch to reading mode.
|
||||
|
||||
Args:
|
||||
book_path: Path to EPUB file to open
|
||||
|
||||
This method:
|
||||
1. Initializes reader if needed
|
||||
2. Loads the book
|
||||
3. Applies saved settings
|
||||
4. Restores reading position
|
||||
5. Updates state
|
||||
6. Renders first/current page
|
||||
"""
|
||||
logger.info(f"Entering reading mode: {book_path}")
|
||||
|
||||
# Verify book exists
|
||||
if not Path(book_path).exists():
|
||||
logger.error(f"Book not found: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
# Initialize reader if needed
|
||||
if not self.reader:
|
||||
logger.info("Initializing ebook reader")
|
||||
self.reader = EbookReader(
|
||||
page_size=self.config.page_size,
|
||||
margin=40,
|
||||
background_color=(255, 255, 255),
|
||||
bookmarks_dir=self.config.bookmarks_dir,
|
||||
highlights_dir=self.config.highlights_dir
|
||||
)
|
||||
|
||||
# Load book
|
||||
logger.info(f"Loading EPUB: {book_path}")
|
||||
success = self.reader.load_epub(book_path)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Failed to load EPUB: {book_path}")
|
||||
# Return to library
|
||||
await self._enter_library_mode()
|
||||
return
|
||||
|
||||
logger.info(f"Loaded: {self.reader.book_title} by {self.reader.book_author}")
|
||||
|
||||
# Apply saved settings
|
||||
logger.info("Applying saved settings")
|
||||
settings_dict = self.state.settings.to_dict()
|
||||
self.reader.apply_settings(settings_dict)
|
||||
|
||||
# Restore position
|
||||
logger.info("Restoring reading position")
|
||||
position_loaded = self.reader.load_position("__auto_resume__")
|
||||
if position_loaded:
|
||||
pos_info = self.reader.get_position_info()
|
||||
logger.info(f"Resumed at position: {pos_info}")
|
||||
else:
|
||||
logger.info("No saved position, starting from beginning")
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_current_book(BookState(
|
||||
path=book_path,
|
||||
title=self.reader.book_title or "Unknown",
|
||||
author=self.reader.book_author or "Unknown"
|
||||
))
|
||||
self.state_manager.set_mode(EreaderMode.READING)
|
||||
|
||||
# Render current page
|
||||
logger.info("Rendering current page")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
logger.info("Reading mode active")
|
||||
|
||||
async def _handle_library_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in library mode.
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
if event.gesture == GestureType.TAP:
|
||||
logger.debug(f"Library tap at ({event.x}, {event.y})")
|
||||
|
||||
# Check if a book was selected
|
||||
book_path = self.library.handle_library_tap(event.x, event.y)
|
||||
|
||||
if book_path:
|
||||
logger.info(f"Book selected: {book_path}")
|
||||
await self._enter_reading_mode(book_path)
|
||||
else:
|
||||
logger.debug("Tap did not hit a book")
|
||||
|
||||
async def _handle_reading_touch(self, event: TouchEvent):
|
||||
"""
|
||||
Handle touch events in reading mode.
|
||||
|
||||
Args:
|
||||
event: Touch event
|
||||
"""
|
||||
# Delegate to reader's gesture handler
|
||||
logger.info(f"[APP] Calling reader.handle_touch({event.gesture.value})")
|
||||
response = self.reader.handle_touch(event)
|
||||
|
||||
# response.action is already a string (ActionType enum value), not the enum itself
|
||||
logger.info(f"[APP] Reader response: action={response.action}, data={response.data}")
|
||||
|
||||
# Handle special actions
|
||||
if response.action == ActionType.BACK_TO_LIBRARY:
|
||||
logger.info("[APP] → Returning to library")
|
||||
await self._enter_library_mode()
|
||||
|
||||
elif response.action == ActionType.PAGE_TURN:
|
||||
logger.info(f"[APP] → Page turned: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_OPENED:
|
||||
logger.info(f"[APP] → Overlay opened: {self.reader.get_overlay_state()}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.OVERLAY_CLOSED:
|
||||
logger.info("[APP] → Overlay closed")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.SETTING_CHANGED:
|
||||
logger.info(f"[APP] → Setting changed: {response.data}")
|
||||
# Update state with new settings
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.CHAPTER_SELECTED:
|
||||
logger.info(f"[APP] → Chapter selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.BOOKMARK_SELECTED:
|
||||
logger.info(f"[APP] → Bookmark selected: {response.data}")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.NAVIGATE:
|
||||
logger.debug("Navigation action")
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ZOOM:
|
||||
logger.info(f"Zoom action: {response.data}")
|
||||
# Font size changed
|
||||
settings = self.reader.get_current_settings()
|
||||
self.state_manager.update_settings(settings)
|
||||
self.current_image = self.reader.get_current_page()
|
||||
|
||||
elif response.action == ActionType.ERROR:
|
||||
logger.error(f"Error: {response.data}")
|
||||
|
||||
async def _update_display(self):
|
||||
"""
|
||||
Update the display with current image.
|
||||
|
||||
This method sends the current image to the HAL for display.
|
||||
"""
|
||||
if self.current_image:
|
||||
logger.debug(f"Updating display: {self.current_image.size}")
|
||||
await self.display_hal.show_image(self.current_image)
|
||||
else:
|
||||
logger.warning("No image to display")
|
||||
|
||||
def get_current_mode(self) -> EreaderMode:
|
||||
"""Get current application mode."""
|
||||
return self.state.mode
|
||||
|
||||
def get_overlay_state(self) -> OverlayState:
|
||||
"""Get current overlay state (only valid in reading mode)."""
|
||||
if self.reader:
|
||||
return self.reader.get_overlay_state()
|
||||
return OverlayState.NONE
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if application is running."""
|
||||
return self.running
|
||||
@@ -168,6 +168,7 @@ class OverlaySubApplication(ABC):
|
||||
Composited PIL Image with popup effect
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user