Coverage for pyPhotoAlbum/mixins/page_navigation.py: 87%
116 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Page navigation mixin for GLWidget - handles page detection and ghost pages
3"""
5from typing import TYPE_CHECKING, Optional, Tuple, List
7if TYPE_CHECKING:
8 from PyQt6.QtWidgets import QMainWindow
11class PageNavigationMixin:
12 # Type hints for expected attributes from mixing class
13 pan_offset: Tuple[float, float]
14 zoom_level: float
16 def update(self) -> None: # type: ignore[empty-body]
17 """Expected from QWidget"""
18 ...
20 def window(self) -> "QMainWindow": # type: ignore[empty-body]
21 """Expected from QWidget"""
22 ...
24 """
25 Mixin providing page navigation and ghost page functionality.
27 This mixin handles page detection from screen coordinates, calculating
28 page positions with ghost pages, and managing ghost page interactions.
29 """
31 def __init__(self, *args, **kwargs):
32 super().__init__(*args, **kwargs)
34 # Current page tracking for operations that need to know which page to work on
35 self.current_page_index: int = 0
37 # Store page renderers for later use (mouse interaction, text overlays, etc.)
38 self._page_renderers: List = []
40 def _get_page_at(self, x: float, y: float):
41 """
42 Get the page at the given screen coordinates.
44 Args:
45 x: Screen X coordinate
46 y: Screen Y coordinate
48 Returns:
49 Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
50 """
51 if not hasattr(self, "_page_renderers") or not self._page_renderers:
52 return None, -1, None
54 main_window = self.window()
55 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
56 return None, -1, None
58 # Check each page to find which one contains the coordinates
59 for renderer, page in self._page_renderers:
60 if renderer.is_point_in_page(x, y):
61 # Find the page index in the project's pages list
62 page_index = main_window.project.pages.index(page)
63 return page, page_index, renderer
65 return None, -1, None
67 def _get_page_positions(self):
68 """
69 Calculate page positions including ghost pages.
71 Returns:
72 List of tuples (page_type, page_or_ghost_data, y_offset)
73 """
74 # Use stored reference to main window
75 main_window = getattr(self, "_main_window", None)
76 if main_window is None:
77 main_window = self.window()
79 try:
80 project = main_window.project
81 if not project:
82 return []
83 except (AttributeError, TypeError):
84 return []
86 dpi = project.working_dpi
88 # Use project's page_spacing_mm setting (default is 10mm = 1cm)
89 # Convert to pixels at working DPI
90 spacing_mm = project.page_spacing_mm
91 spacing_px = spacing_mm * dpi / 25.4
93 # Start with a small top margin (5mm)
94 top_margin_mm = 5.0
95 top_margin_px = top_margin_mm * dpi / 25.4
97 result = []
98 current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
100 # First, render cover if it exists
101 for page in project.pages:
102 if page.is_cover:
103 result.append(("page", page, current_y))
105 # Calculate cover height in pixels
106 page_height_mm = page.layout.size[1]
107 page_height_px = page_height_mm * dpi / 25.4
109 # Move to next position (add height + spacing)
110 current_y += page_height_px + spacing_px
111 break # Only one cover allowed
113 # Get page layout with ghosts from project (this excludes cover)
114 layout_with_ghosts = project.calculate_page_layout_with_ghosts()
116 for page_type, page_obj, logical_pos in layout_with_ghosts:
117 if page_type == "page":
118 # Regular page (single or double spread)
119 result.append((page_type, page_obj, current_y))
121 # Calculate page height in pixels
122 # For double spreads, layout.size already contains the doubled width
123 page_height_mm = page_obj.layout.size[1]
124 page_height_px = page_height_mm * dpi / 25.4
126 # Move to next position (add height + spacing)
127 current_y += page_height_px + spacing_px
129 elif page_type == "ghost":
130 # Ghost page - use default page size
131 page_size_mm = project.page_size_mm
132 from pyPhotoAlbum.models import GhostPageData
134 # Create ghost page data with correct size
135 ghost = GhostPageData(page_size=page_size_mm)
136 result.append((page_type, ghost, current_y))
138 # Calculate ghost page height
139 page_height_px = page_size_mm[1] * dpi / 25.4
141 # Move to next position (add height + spacing)
142 current_y += page_height_px + spacing_px
144 return result
146 def _check_ghost_page_click(self, x: float, y: float) -> bool:
147 """
148 Check if click is on a ghost page (entire page is clickable) and handle it.
150 Args:
151 x: Screen X coordinate
152 y: Screen Y coordinate
154 Returns:
155 bool: True if a ghost page was clicked and a new page was created
156 """
157 if not hasattr(self, "_page_renderers"):
158 return False
160 main_window = self.window()
161 if not hasattr(main_window, "project"):
162 return False
164 # Get page positions which includes ghosts
165 page_positions = self._get_page_positions()
167 # Check each position for ghost pages
168 for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
169 # Skip non-ghost pages
170 if page_type != "ghost":
171 continue
173 ghost = page_or_ghost
174 dpi = main_window.project.working_dpi
176 # Calculate ghost page renderer
177 ghost_width_mm, ghost_height_mm = ghost.page_size
178 screen_x = 50 + self.pan_offset[0]
179 screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
181 from pyPhotoAlbum.page_renderer import PageRenderer
183 renderer = PageRenderer(
184 page_width_mm=ghost_width_mm,
185 page_height_mm=ghost_height_mm,
186 screen_x=screen_x,
187 screen_y=screen_y,
188 dpi=dpi,
189 zoom=self.zoom_level,
190 )
192 # Check if click is anywhere on the ghost page (entire page is clickable)
193 if renderer.is_point_in_page(x, y):
194 # User clicked the ghost page!
195 # Calculate the insertion index (count real pages before this ghost in page_positions)
196 insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
198 print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
200 # Create a new page and insert it directly into the pages list
201 from pyPhotoAlbum.project import Page
202 from pyPhotoAlbum.page_layout import PageLayout
204 # Create new page with next page number
205 new_page_number = insert_index + 1
206 new_page = Page(
207 layout=PageLayout(
208 width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
209 ),
210 page_number=new_page_number,
211 )
213 # Insert the page at the correct position
214 main_window.project.pages.insert(insert_index, new_page)
216 # Renumber all pages after this one
217 for i, page in enumerate(main_window.project.pages):
218 page.page_number = i + 1
220 print(f"Inserted page at index {insert_index}, renumbered pages")
221 self.update()
222 return True
224 return False
226 def _update_page_status(self, x: float, y: float):
227 """
228 Update status bar with current page and total page count.
230 Args:
231 x: Screen X coordinate
232 y: Screen Y coordinate
233 """
234 main_window = self.window()
235 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
236 return
238 if not hasattr(self, "_page_renderers") or not self._page_renderers:
239 return
241 # Get total page count (accounting for double spreads = 2 pages each)
242 total_pages = sum(page.get_page_count() for page in main_window.project.pages)
244 # Find which page mouse is over
245 current_page_info = None
247 for renderer, page in self._page_renderers:
248 # Check if mouse is within this page bounds
249 if renderer.is_point_in_page(x, y):
250 # For facing page spreads, determine left or right
251 if page.is_double_spread:
252 side = renderer.get_sub_page_at(x, is_facing_page=True)
253 page_nums = page.get_page_numbers()
254 if side == "left":
255 current_page_info = f"Page {page_nums[0]}"
256 else:
257 current_page_info = f"Page {page_nums[1]}"
258 else:
259 current_page_info = f"Page {page.page_number}"
260 break
262 # Update status bar
263 if hasattr(main_window, "status_bar"):
264 if current_page_info:
265 main_window.status_bar.showMessage(
266 f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
267 )
268 else:
269 main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")