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

1""" 

2Page navigation mixin for GLWidget - handles page detection and ghost pages 

3""" 

4 

5from typing import TYPE_CHECKING, Optional, Tuple, List 

6 

7if TYPE_CHECKING: 

8 from PyQt6.QtWidgets import QMainWindow 

9 

10 

11class PageNavigationMixin: 

12 # Type hints for expected attributes from mixing class 

13 pan_offset: Tuple[float, float] 

14 zoom_level: float 

15 

16 def update(self) -> None: # type: ignore[empty-body] 

17 """Expected from QWidget""" 

18 ... 

19 

20 def window(self) -> "QMainWindow": # type: ignore[empty-body] 

21 """Expected from QWidget""" 

22 ... 

23 

24 """ 

25 Mixin providing page navigation and ghost page functionality. 

26 

27 This mixin handles page detection from screen coordinates, calculating 

28 page positions with ghost pages, and managing ghost page interactions. 

29 """ 

30 

31 def __init__(self, *args, **kwargs): 

32 super().__init__(*args, **kwargs) 

33 

34 # Current page tracking for operations that need to know which page to work on 

35 self.current_page_index: int = 0 

36 

37 # Store page renderers for later use (mouse interaction, text overlays, etc.) 

38 self._page_renderers: List = [] 

39 

40 def _get_page_at(self, x: float, y: float): 

41 """ 

42 Get the page at the given screen coordinates. 

43 

44 Args: 

45 x: Screen X coordinate 

46 y: Screen Y coordinate 

47 

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 

53 

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 

57 

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 

64 

65 return None, -1, None 

66 

67 def _get_page_positions(self): 

68 """ 

69 Calculate page positions including ghost pages. 

70 

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() 

78 

79 try: 

80 project = main_window.project 

81 if not project: 

82 return [] 

83 except (AttributeError, TypeError): 

84 return [] 

85 

86 dpi = project.working_dpi 

87 

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 

92 

93 # Start with a small top margin (5mm) 

94 top_margin_mm = 5.0 

95 top_margin_px = top_margin_mm * dpi / 25.4 

96 

97 result = [] 

98 current_y = top_margin_px # Initial top offset in pixels (not screen pixels) 

99 

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)) 

104 

105 # Calculate cover height in pixels 

106 page_height_mm = page.layout.size[1] 

107 page_height_px = page_height_mm * dpi / 25.4 

108 

109 # Move to next position (add height + spacing) 

110 current_y += page_height_px + spacing_px 

111 break # Only one cover allowed 

112 

113 # Get page layout with ghosts from project (this excludes cover) 

114 layout_with_ghosts = project.calculate_page_layout_with_ghosts() 

115 

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)) 

120 

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 

125 

126 # Move to next position (add height + spacing) 

127 current_y += page_height_px + spacing_px 

128 

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 

133 

134 # Create ghost page data with correct size 

135 ghost = GhostPageData(page_size=page_size_mm) 

136 result.append((page_type, ghost, current_y)) 

137 

138 # Calculate ghost page height 

139 page_height_px = page_size_mm[1] * dpi / 25.4 

140 

141 # Move to next position (add height + spacing) 

142 current_y += page_height_px + spacing_px 

143 

144 return result 

145 

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. 

149 

150 Args: 

151 x: Screen X coordinate 

152 y: Screen Y coordinate 

153 

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 

159 

160 main_window = self.window() 

161 if not hasattr(main_window, "project"): 

162 return False 

163 

164 # Get page positions which includes ghosts 

165 page_positions = self._get_page_positions() 

166 

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 

172 

173 ghost = page_or_ghost 

174 dpi = main_window.project.working_dpi 

175 

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] 

180 

181 from pyPhotoAlbum.page_renderer import PageRenderer 

182 

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 ) 

191 

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") 

197 

198 print(f"Ghost page clicked at index {insert_index} - inserting new page in place") 

199 

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 

203 

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 ) 

212 

213 # Insert the page at the correct position 

214 main_window.project.pages.insert(insert_index, new_page) 

215 

216 # Renumber all pages after this one 

217 for i, page in enumerate(main_window.project.pages): 

218 page.page_number = i + 1 

219 

220 print(f"Inserted page at index {insert_index}, renumbered pages") 

221 self.update() 

222 return True 

223 

224 return False 

225 

226 def _update_page_status(self, x: float, y: float): 

227 """ 

228 Update status bar with current page and total page count. 

229 

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 

237 

238 if not hasattr(self, "_page_renderers") or not self._page_renderers: 

239 return 

240 

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) 

243 

244 # Find which page mouse is over 

245 current_page_info = None 

246 

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 

261 

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)}%")