diff --git a/dreader/application.py b/dreader/application.py index 89bdae8..73c9611 100644 --- a/dreader/application.py +++ b/dreader/application.py @@ -825,6 +825,85 @@ class EbookReader: self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + # For navigation overlay, handle tab switching, chapter/bookmark selection, and close + elif self.current_overlay_state == OverlayState.NAVIGATION: + # Query the overlay to see what was tapped + query_result = self.overlay_manager.query_overlay_pixel(x, y) + + # If query failed (tap outside overlay), close it + if not query_result: + self.close_overlay() + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Check if tapped on a link + if query_result.get("is_interactive") and query_result.get("link_target"): + link_target = query_result["link_target"] + + # Parse "tab:tabname" format for tab switching + if link_target.startswith("tab:"): + tab_name = link_target.split(":", 1)[1] + # Switch to the selected tab + self.switch_navigation_tab(tab_name) + return GestureResponse(ActionType.TAB_SWITCHED, { + "tab": tab_name + }) + + # Parse "chapter:N" format for chapter navigation + elif link_target.startswith("chapter:"): + try: + chapter_idx = int(link_target.split(":")[1]) + + # Get chapter title for response + chapters = self.get_chapters() + chapter_title = None + for title, idx in chapters: + if idx == chapter_idx: + chapter_title = title + break + + # Jump to selected chapter + self.jump_to_chapter(chapter_idx) + + # Close overlay + self.close_overlay() + + return GestureResponse(ActionType.CHAPTER_SELECTED, { + "chapter_index": chapter_idx, + "chapter_title": chapter_title or f"Chapter {chapter_idx}" + }) + except (ValueError, IndexError): + pass + + # Parse "bookmark:name" format for bookmark navigation + elif link_target.startswith("bookmark:"): + bookmark_name = link_target.split(":", 1)[1] + + # Load the bookmark position + page = self.load_position(bookmark_name) + if page: + # Close overlay + self.close_overlay() + + return GestureResponse(ActionType.BOOKMARK_SELECTED, { + "bookmark_name": bookmark_name + }) + else: + # Failed to load bookmark + return GestureResponse(ActionType.ERROR, { + "message": f"Failed to load bookmark: {bookmark_name}" + }) + + # Parse "action:close" format for close button + elif link_target.startswith("action:"): + action = link_target.split(":", 1)[1] + if action == "close": + self.close_overlay() + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Not an interactive element, close overlay + self.close_overlay() + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + # For other overlays, just close on any tap for now self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) @@ -1127,6 +1206,64 @@ class EbookReader: return result + def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]: + """ + Open the unified navigation overlay with Contents and Bookmarks tabs. + + This is the new unified overlay that replaces separate TOC and Bookmarks overlays. + It provides a tabbed interface for switching between table of contents and bookmarks. + + Args: + active_tab: Which tab to show initially ("contents" or "bookmarks") + + Returns: + Composited image with navigation overlay on top of current page, or None if no book loaded + """ + if not self.is_loaded(): + return None + + # Get current page as base + base_page = self.get_current_page(include_highlights=False) + if not base_page: + return None + + # Get chapters for Contents tab + chapters = self.get_chapters() + + # Get bookmarks for Bookmarks tab + bookmark_names = self.list_saved_positions() + bookmarks = [ + {"name": name, "position": f"Saved position"} + for name in bookmark_names + ] + + # Open overlay and get composited image + result = self.overlay_manager.open_navigation_overlay( + chapters=chapters, + bookmarks=bookmarks, + base_page=base_page, + active_tab=active_tab + ) + self.current_overlay_state = OverlayState.NAVIGATION + + return result + + def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]: + """ + Switch between tabs in the navigation overlay. + + Args: + new_tab: Tab to switch to ("contents" or "bookmarks") + + Returns: + Updated image with new tab active, or None if navigation overlay is not open + """ + if self.current_overlay_state != OverlayState.NAVIGATION: + return None + + result = self.overlay_manager.switch_navigation_tab(new_tab) + return result if result else self.get_current_page() + def close_overlay(self) -> Optional[Image.Image]: """ Close the current overlay and return to reading view. diff --git a/dreader/gesture.py b/dreader/gesture.py index 74eee5b..ffcd2aa 100644 --- a/dreader/gesture.py +++ b/dreader/gesture.py @@ -125,5 +125,7 @@ class ActionType: OVERLAY_OPENED = "overlay_opened" OVERLAY_CLOSED = "overlay_closed" CHAPTER_SELECTED = "chapter_selected" + BOOKMARK_SELECTED = "bookmark_selected" + TAB_SWITCHED = "tab_switched" SETTING_CHANGED = "setting_changed" BACK_TO_LIBRARY = "back_to_library" diff --git a/dreader/html_generator.py b/dreader/html_generator.py index 4feda1a..ff63088 100644 --- a/dreader/html_generator.py +++ b/dreader/html_generator.py @@ -502,3 +502,136 @@ def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str: ''' return html + + +def generate_navigation_overlay( + chapters: List[Dict], + bookmarks: List[Dict], + active_tab: str = "contents", + page_size: tuple = (800, 1200) +) -> str: + """ + Generate HTML for the unified navigation overlay with Contents and Bookmarks tabs. + + This combines TOC and Bookmarks into a single overlay with tab switching. + Tabs are clickable links that switch between contents (tab:contents) and bookmarks (tab:bookmarks). + + Args: + chapters: List of chapter dictionaries with keys: + - index: Chapter index + - title: Chapter title + bookmarks: List of bookmark dictionaries with keys: + - name: Bookmark name + - position: Position info (optional) + active_tab: Which tab to show ("contents" or "bookmarks") + page_size: Page dimensions (width, height) for sizing the overlay + + Returns: + HTML string for navigation overlay with tab switching + """ + # Build chapter list items with clickable links + chapter_items = [] + for i, chapter in enumerate(chapters): + title = chapter["title"] + link_text = f'{i+1}. {title}' + if len(title) <= 2: + link_text = f'{i+1}. {title} ' # Extra spaces for padding + + chapter_items.append( + f'

' + f'' + f'{link_text}

' + ) + + # Build bookmark list items with clickable links + bookmark_items = [] + for bookmark in bookmarks: + name = bookmark['name'] + position_text = bookmark.get('position', 'Saved position') + + bookmark_items.append( + f'

' + f'' + f'{name}' + f'{position_text}' + f'

' + ) + + # Determine which content to show + contents_display = "block" if active_tab == "contents" else "none" + bookmarks_display = "block" if active_tab == "bookmarks" else "none" + + # Style active tab + contents_tab_style = "background-color: #000; color: #fff;" if active_tab == "contents" else "background-color: #f0f0f0; color: #000;" + bookmarks_tab_style = "background-color: #000; color: #fff;" if active_tab == "bookmarks" else "background-color: #f0f0f0; color: #000;" + + chapters_html = ''.join(chapter_items) if chapter_items else '

No chapters available

' + bookmarks_html = ''.join(bookmark_items) if bookmark_items else '

No bookmarks yet

' + + html = f''' + + + + + Navigation + + + + +
+ + Contents + + + Bookmarks + +
+ + +
+

+ Table of Contents +

+

+ {len(chapters)} chapters +

+
+ {chapters_html} +
+
+ + +
+

+ Bookmarks +

+

+ {len(bookmarks)} saved +

+
+ {bookmarks_html} +
+
+ + +
+ + Close + +
+ + + +''' + return html diff --git a/dreader/overlay.py b/dreader/overlay.py index 93e1d65..a20b69c 100644 --- a/dreader/overlay.py +++ b/dreader/overlay.py @@ -14,7 +14,8 @@ from .state import OverlayState from .html_generator import ( generate_toc_overlay, generate_settings_overlay, - generate_bookmarks_overlay + generate_bookmarks_overlay, + generate_navigation_overlay ) @@ -370,6 +371,115 @@ class OverlayManager: # Composite and return return self.composite_overlay(base_page, overlay_image) + def open_navigation_overlay( + self, + chapters: List[Tuple[str, int]], + bookmarks: List[Dict], + base_page: Image.Image, + active_tab: str = "contents" + ) -> Image.Image: + """ + Open the unified navigation overlay with Contents and Bookmarks tabs. + + This replaces the separate TOC and Bookmarks overlays with a single + overlay that has tabs for switching between contents and bookmarks. + + Args: + chapters: List of (chapter_title, chapter_index) tuples + bookmarks: List of bookmark dictionaries with 'name' and optional 'position' + base_page: Current reading page to show underneath + active_tab: Which tab to show ("contents" or "bookmarks") + + Returns: + Composited image with navigation overlay on top + """ + # Import here to avoid circular dependency + from .application import EbookReader + + # Calculate panel size (60% of screen width, 70% height) + panel_width = int(self.page_size[0] * 0.6) + panel_height = int(self.page_size[1] * 0.7) + + # Convert chapters to format expected by HTML generator + chapter_data = [ + {"index": idx, "title": title} + for title, idx in chapters + ] + + # Generate navigation HTML with tabs + html = generate_navigation_overlay( + chapters=chapter_data, + bookmarks=bookmarks, + active_tab=active_tab, + page_size=(panel_width, panel_height) + ) + + # Create reader for overlay and keep it alive for querying + if self._overlay_reader: + self._overlay_reader.close() + + self._overlay_reader = EbookReader( + page_size=(panel_width, panel_height), + margin=15, + background_color=(255, 255, 255) + ) + + # Load the HTML content + success = self._overlay_reader.load_html( + html_string=html, + title="Navigation", + author="", + document_id="navigation_overlay" + ) + + if not success: + raise ValueError("Failed to load navigation overlay HTML") + + # Get the rendered page + overlay_panel = self._overlay_reader.get_current_page() + + # Calculate and store panel position for coordinate translation + panel_x = int((self.page_size[0] - panel_width) / 2) + panel_y = int((self.page_size[1] - panel_height) / 2) + self._overlay_panel_offset = (panel_x, panel_y) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_panel + self.current_overlay = OverlayState.NAVIGATION + + # Store active tab for tab switching + self._active_nav_tab = active_tab + self._cached_chapters = chapters + self._cached_bookmarks = bookmarks + + # Composite and return + return self.composite_overlay(base_page, overlay_panel) + + def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]: + """ + Switch between tabs in the navigation overlay. + + Args: + new_tab: Tab to switch to ("contents" or "bookmarks") + + Returns: + Updated composited image with new tab active, or None if not in navigation overlay + """ + if self.current_overlay != OverlayState.NAVIGATION: + return None + + # Re-open navigation overlay with new active tab + if hasattr(self, '_cached_chapters') and hasattr(self, '_cached_bookmarks'): + return self.open_navigation_overlay( + chapters=self._cached_chapters, + bookmarks=self._cached_bookmarks, + base_page=self._cached_base_page, + active_tab=new_tab + ) + + return None + def close_overlay(self) -> Optional[Image.Image]: """ Close the current overlay and return to base page. diff --git a/dreader/state.py b/dreader/state.py index eb9c12d..24fb432 100644 --- a/dreader/state.py +++ b/dreader/state.py @@ -27,9 +27,10 @@ class EreaderMode(Enum): class OverlayState(Enum): """Overlay states within READING mode""" NONE = "none" - TOC = "toc" + TOC = "toc" # Deprecated: use NAVIGATION instead SETTINGS = "settings" - BOOKMARKS = "bookmarks" + BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead + NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks @dataclass diff --git a/examples/navigation_overlay_example.py b/examples/navigation_overlay_example.py new file mode 100644 index 0000000..bfe56c2 --- /dev/null +++ b/examples/navigation_overlay_example.py @@ -0,0 +1,196 @@ +""" +Example demonstrating the unified navigation overlay feature. + +This example shows how to: +1. Open the navigation overlay with Contents and Bookmarks tabs +2. Switch between tabs +3. Navigate to chapters and bookmarks +4. Handle user interactions with the overlay + +The navigation overlay replaces the separate TOC and Bookmarks overlays +with a single, unified interface that provides both features in a tabbed view. +""" + +from pathlib import Path +from dreader.application import EbookReader +from dreader.state import OverlayState + +def main(): + # Create reader instance + reader = EbookReader(page_size=(800, 1200), margin=20) + + # Load a sample book (adjust path as needed) + book_path = Path(__file__).parent / "books" / "hamlet.epub" + if not book_path.exists(): + print(f"Book not found at {book_path}") + print("Creating a simple HTML book for demo...") + + # Create a simple multi-chapter book + html = """ + + Demo Book + +

Chapter 1: Introduction

+

This is the first chapter with some introductory content.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+ +

Chapter 2: Main Content

+

This is the second chapter with main content.

+

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+ +

Chapter 3: Conclusion

+

This is the final chapter with concluding remarks.

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco.

+ + + """ + reader.load_html( + html_string=html, + title="Demo Book", + author="Example Author", + document_id="demo_navigation" + ) + else: + print(f"Loading book: {book_path}") + reader.load_epub(str(book_path)) + + print("\n=== Navigation Overlay Demo ===\n") + + # Display current page + position_info = reader.get_position_info() + print(f"Current position: {position_info}") + print(f"Reading progress: {reader.get_reading_progress():.1%}") + + # Get chapters + chapters = reader.get_chapters() + print(f"\nAvailable chapters: {len(chapters)}") + for i, (title, idx) in enumerate(chapters[:5]): # Show first 5 + print(f" {i+1}. {title}") + + # Save some bookmarks for demonstration + print("\n--- Saving bookmarks ---") + reader.save_position("Start of Book") + print("Saved bookmark: 'Start of Book'") + + reader.next_page() + reader.next_page() + reader.save_position("Chapter 1 Progress") + print("Saved bookmark: 'Chapter 1 Progress'") + + # List saved bookmarks + bookmarks = reader.list_saved_positions() + print(f"\nTotal bookmarks: {len(bookmarks)}") + for name in bookmarks: + print(f" - {name}") + + # === Demo 1: Open navigation overlay with Contents tab === + print("\n\n--- Demo 1: Opening Navigation Overlay (Contents Tab) ---") + image = reader.open_navigation_overlay(active_tab="contents") + + if image: + print(f"✓ Navigation overlay opened successfully") + print(f" Overlay state: {reader.get_overlay_state()}") + print(f" Is overlay open: {reader.is_overlay_open()}") + print(f" Image size: {image.size}") + + # Save the rendered overlay for inspection + output_path = Path("/tmp/navigation_overlay_contents.png") + image.save(output_path) + print(f" Saved to: {output_path}") + + # === Demo 2: Switch to Bookmarks tab === + print("\n\n--- Demo 2: Switching to Bookmarks Tab ---") + image = reader.switch_navigation_tab("bookmarks") + + if image: + print(f"✓ Switched to Bookmarks tab") + print(f" Overlay state: {reader.get_overlay_state()}") + + # Save the rendered overlay for inspection + output_path = Path("/tmp/navigation_overlay_bookmarks.png") + image.save(output_path) + print(f" Saved to: {output_path}") + + # === Demo 3: Switch back to Contents tab === + print("\n\n--- Demo 3: Switching back to Contents Tab ---") + image = reader.switch_navigation_tab("contents") + + if image: + print(f"✓ Switched back to Contents tab") + + # Save the rendered overlay for inspection + output_path = Path("/tmp/navigation_overlay_contents_2.png") + image.save(output_path) + print(f" Saved to: {output_path}") + + # === Demo 4: Close overlay === + print("\n\n--- Demo 4: Closing Navigation Overlay ---") + image = reader.close_overlay() + + if image: + print(f"✓ Overlay closed successfully") + print(f" Overlay state: {reader.get_overlay_state()}") + print(f" Is overlay open: {reader.is_overlay_open()}") + + # === Demo 5: Open with Bookmarks tab directly === + print("\n\n--- Demo 5: Opening directly to Bookmarks Tab ---") + image = reader.open_navigation_overlay(active_tab="bookmarks") + + if image: + print(f"✓ Navigation overlay opened with Bookmarks tab") + + # Save the rendered overlay for inspection + output_path = Path("/tmp/navigation_overlay_bookmarks_direct.png") + image.save(output_path) + print(f" Saved to: {output_path}") + + # Close overlay + reader.close_overlay() + + # === Demo 6: Simulate user interaction flow === + print("\n\n--- Demo 6: Simulated User Interaction Flow ---") + print("Simulating: User opens overlay, switches tabs, selects bookmark") + + # 1. User opens navigation overlay + print("\n 1. User taps navigation button -> Opens overlay with Contents tab") + reader.open_navigation_overlay(active_tab="contents") + print(f" State: {reader.get_overlay_state()}") + + # 2. User switches to Bookmarks tab + print("\n 2. User taps 'Bookmarks' tab") + reader.switch_navigation_tab("bookmarks") + print(f" State: {reader.get_overlay_state()}") + + # 3. User selects a bookmark + print("\n 3. User taps on bookmark 'Start of Book'") + page = reader.load_position("Start of Book") + if page: + print(f" ✓ Loaded bookmark successfully") + print(f" Position: {reader.get_position_info()}") + + # 4. Close overlay + print("\n 4. System closes overlay after selection") + reader.close_overlay() + print(f" State: {reader.get_overlay_state()}") + + # === Summary === + print("\n\n=== Demo Complete ===") + print(f"\nGenerated overlay images in /tmp:") + print(f" - navigation_overlay_contents.png") + print(f" - navigation_overlay_bookmarks.png") + print(f" - navigation_overlay_contents_2.png") + print(f" - navigation_overlay_bookmarks_direct.png") + + print("\n✓ Navigation overlay provides unified interface for:") + print(" • Table of Contents (chapter navigation)") + print(" • Bookmarks (saved positions)") + print(" • Tab switching between Contents and Bookmarks") + print(" • Consistent interaction patterns") + + # Cleanup + reader.close() + print("\nReader closed.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_navigation_overlay.py b/tests/test_navigation_overlay.py new file mode 100644 index 0000000..f1e2116 --- /dev/null +++ b/tests/test_navigation_overlay.py @@ -0,0 +1,196 @@ +""" +Tests for the unified navigation overlay (TOC + Bookmarks tabs) +""" +import pytest +from pathlib import Path +from PIL import Image + +from dreader.application import EbookReader +from dreader.state import OverlayState +from dreader.gesture import TouchEvent, GestureType, ActionType + + +@pytest.fixture +def reader_with_book(): + """Create a reader with a test book loaded""" + reader = EbookReader(page_size=(400, 600), margin=10) + + # Load a simple test book + test_book = Path(__file__).parent.parent / "examples" / "books" / "hamlet.epub" + if test_book.exists(): + reader.load_epub(str(test_book)) + else: + # Fallback: create simple HTML for testing + html = """ + + +

Chapter 1

+

This is chapter 1 content

+

Chapter 2

+

This is chapter 2 content

+ + + """ + reader.load_html(html, title="Test Book", author="Test Author", document_id="test") + + yield reader + reader.close() + + +def test_open_navigation_overlay_contents_tab(reader_with_book): + """Test opening navigation overlay with Contents tab active""" + reader = reader_with_book + + # Open navigation overlay with contents tab + image = reader.open_navigation_overlay(active_tab="contents") + + assert image is not None + assert isinstance(image, Image.Image) + assert reader.get_overlay_state() == OverlayState.NAVIGATION + assert reader.is_overlay_open() + + +def test_open_navigation_overlay_bookmarks_tab(reader_with_book): + """Test opening navigation overlay with Bookmarks tab active""" + reader = reader_with_book + + # Save a bookmark first + reader.save_position("Test Bookmark") + + # Open navigation overlay with bookmarks tab + image = reader.open_navigation_overlay(active_tab="bookmarks") + + assert image is not None + assert isinstance(image, Image.Image) + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_switch_navigation_tabs(reader_with_book): + """Test switching between Contents and Bookmarks tabs""" + reader = reader_with_book + + # Open with contents tab + reader.open_navigation_overlay(active_tab="contents") + + # Switch to bookmarks + image = reader.switch_navigation_tab("bookmarks") + assert image is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + # Switch back to contents + image = reader.switch_navigation_tab("contents") + assert image is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_close_navigation_overlay(reader_with_book): + """Test closing navigation overlay""" + reader = reader_with_book + + # Open overlay + reader.open_navigation_overlay() + assert reader.is_overlay_open() + + # Close overlay + image = reader.close_overlay() + assert image is not None + assert not reader.is_overlay_open() + assert reader.get_overlay_state() == OverlayState.NONE + + +def test_navigation_overlay_tab_switching_gesture(reader_with_book): + """Test tab switching via gesture/touch handling""" + reader = reader_with_book + + # Open navigation overlay + reader.open_navigation_overlay(active_tab="contents") + + # Query the overlay to find the bookmarks tab button + # This would normally be done by finding the coordinates of the "Bookmarks" tab + # For now, we test that the switch method works + result = reader.switch_navigation_tab("bookmarks") + + assert result is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_navigation_overlay_with_no_bookmarks(reader_with_book): + """Test navigation overlay when there are no bookmarks""" + reader = reader_with_book + + # Open bookmarks tab (should show "No bookmarks yet") + image = reader.open_navigation_overlay(active_tab="bookmarks") + + assert image is not None + # The overlay should still open successfully + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_navigation_overlay_preserves_page_position(reader_with_book): + """Test that opening/closing navigation overlay preserves reading position""" + reader = reader_with_book + + # Go to page 2 + reader.next_page() + initial_position = reader.get_position_info() + + # Open and close navigation overlay + reader.open_navigation_overlay() + reader.close_overlay() + + # Verify position hasn't changed + final_position = reader.get_position_info() + assert initial_position == final_position + + +def test_navigation_overlay_chapter_selection(reader_with_book): + """Test selecting a chapter from the navigation overlay""" + reader = reader_with_book + + # Get chapters + chapters = reader.get_chapters() + if len(chapters) < 2: + pytest.skip("Test book doesn't have enough chapters") + + # Open navigation overlay + reader.open_navigation_overlay(active_tab="contents") + + # Get initial position + initial_position = reader.get_position_info() + + # Jump to chapter via the reader method (simulating a tap on chapter) + reader.jump_to_chapter(chapters[1][1]) # chapters[1] = (title, index) + reader.close_overlay() + + # Verify position changed + new_position = reader.get_position_info() + assert new_position != initial_position + + +def test_navigation_overlay_bookmark_selection(reader_with_book): + """Test selecting a bookmark from the navigation overlay""" + reader = reader_with_book + + # Save a bookmark at page 1 + reader.save_position("Bookmark 1") + + # Move to a different page + reader.next_page() + position_before = reader.get_position_info() + + # Open navigation overlay with bookmarks tab + reader.open_navigation_overlay(active_tab="bookmarks") + + # Load the bookmark (simulating a tap on bookmark) + page = reader.load_position("Bookmark 1") + assert page is not None + + reader.close_overlay() + + # Verify position changed back to bookmark + position_after = reader.get_position_info() + assert position_after != position_before + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])