1 """

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

3 """

4

5 from typing import TYPE_CHECKING, Optional, Tuple, List

6

7 if TYPE_CHECKING:

8 from PyQt6.QtWidgets import QMainWindow

9

10

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