1 """

2 Viewport mixin for GLWidget - handles zoom and pan

3 """

4

5 from pyPhotoAlbum.gl_imports import *

6

7

8 class ViewportMixin:

9 """

10 Mixin providing viewport zoom and pan functionality.

11

12 This mixin manages the zoom level and pan offset for the OpenGL canvas,

13 including fit-to-screen calculations and OpenGL initialization.

14 """

15

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

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

18

19 # Zoom and pan state

20 self.zoom_level = 1.0

21 self.pan_offset = [0, 0]

22 self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom

23

24 # Track previous viewport size to detect scrollbar-induced resizes

25 self._last_viewport_size = (0, 0)

26

27 def initializeGL(self):

28 """Initialize OpenGL resources"""

29 glClearColor(1.0, 1.0, 1.0, 1.0)

30 glEnable(GL_DEPTH_TEST)

31

32 def resizeGL(self, w, h):

33 """Handle window resizing"""

34 glViewport(0, 0, w, h)

35 glMatrixMode(GL_PROJECTION)

36 glLoadIdentity()

37 glOrtho(0, w, h, 0, -1, 1)

38 glMatrixMode(GL_MODELVIEW)

39

40 # Detect if this is a small resize (likely scrollbar visibility change)

41 # Scrollbars are typically 14-20 pixels wide

42 last_w, last_h = self._last_viewport_size

43 width_change = abs(w - last_w)

44 height_change = abs(h - last_h)

45 is_small_resize = width_change <= 20 and height_change <= 20

46 is_first_resize = last_w == 0 and last_h == 0

47

48 # Recalculate centering if we have a project loaded

49 # Recenter on:

50 # 1. First resize (initial setup)

51 # 2. Large resizes (window resize, NOT scrollbar changes)

52 # Don't recenter on small resizes (scrollbar visibility changes during zoom)

53 if self.initial_zoom_set and (is_first_resize or not is_small_resize):

54 # Maintain current zoom level, just recenter

55 self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)

56

57 # Update tracked viewport size

58 self._last_viewport_size = (w, h)

59

60 self.update()

61

62 # Update scrollbars when viewport size changes

63 main_window = self.window()

64 if hasattr(main_window, "update_scrollbars"):

65 main_window.update_scrollbars()

66

67 def _calculate_fit_to_screen_zoom(self):

68 """

69 Calculate zoom level to fit first page to screen.

70

71 Returns:

72 float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)

73 """

74 main_window = self.window()

75 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:

76 return 1.0

77

78 window_width = self.width()

79 window_height = self.height()

80

81 # Get first page dimensions in mm

82 first_page = main_window.project.pages[0]

83 page_width_mm, page_height_mm = first_page.layout.size

84

85 # Convert to pixels

86 dpi = main_window.project.working_dpi

87 page_width_px = page_width_mm * dpi / 25.4

88 page_height_px = page_height_mm * dpi / 25.4

89

90 # Calculate zoom to fit with margins

91 margin = 100 # pixels

92 zoom_w = (window_width - margin * 2) / page_width_px

93 zoom_h = (window_height - margin * 2) / page_height_px

94

95 # Use the smaller zoom to ensure entire page fits

96 return min(zoom_w, zoom_h, 1.0) # Don't zoom in beyond 100%

97

98 def _calculate_center_pan_offset(self, zoom_level):

99 """

100 Calculate pan offset to center the first page in the viewport.

101

102 Args:

103 zoom_level: The current zoom level to use for calculations

104

105 Returns:

106 list: [x_offset, y_offset] to center the page

107 """

108 main_window = self.window()

109 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:

110 return [0, 0]

111

112 window_width = self.width()

113 window_height = self.height()

114

115 # Get first page dimensions in mm

116 first_page = main_window.project.pages[0]

117 page_width_mm, page_height_mm = first_page.layout.size

118

119 # Convert to pixels

120 dpi = main_window.project.working_dpi

121 page_width_px = page_width_mm * dpi / 25.4

122 page_height_px = page_height_mm * dpi / 25.4

123

124 # Apply zoom to get screen dimensions

125 screen_page_width = page_width_px * zoom_level

126 screen_page_height = page_height_px * zoom_level

127

128 # Calculate offsets to center the page

129 # PAGE_MARGIN from rendering.py is 50

130 PAGE_MARGIN = 50

131 x_offset = (window_width - screen_page_width) / 2 - PAGE_MARGIN

132 y_offset = (window_height - screen_page_height) / 2

133

134 return [x_offset, y_offset]

135

136 def get_content_bounds(self):

137 """

138 Calculate the total bounds of all content (pages).

139

140 Returns:

141 dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels

142 """

143 main_window = self.window()

144 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:

145 return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}

146

147 dpi = main_window.project.working_dpi

148 PAGE_MARGIN = 50

149 PAGE_SPACING = 50

150

151 # Calculate total dimensions

152 total_height = PAGE_MARGIN

153 max_width = 0

154

155 for page in main_window.project.pages:

156 page_width_mm, page_height_mm = page.layout.size

157 page_width_px = page_width_mm * dpi / 25.4

158 page_height_px = page_height_mm * dpi / 25.4

159

160 screen_page_width = page_width_px * self.zoom_level

161 screen_page_height = page_height_px * self.zoom_level

162

163 total_height += screen_page_height + PAGE_SPACING

164 max_width = max(max_width, screen_page_width)

165

166 total_width = max_width + PAGE_MARGIN * 2

167 total_height += PAGE_MARGIN

168

169 return {

170 "min_x": 0,

171 "max_x": total_width,

172 "min_y": 0,

173 "max_y": total_height,

174 "width": total_width,

175 "height": total_height,

176 }

177

178 def _clamp_vertical_pan(self, viewport_height: float) -> float:

179 """Clamp vertical pan offset and return the original value before clamping."""

180 bounds = self.get_content_bounds()

181 content_height = bounds["height"]

182

183 # Save original for page selection (prevents clamping from changing which page we target)

184 original_pan_y: float = self.pan_offset[1]

185

186 if content_height > viewport_height:

187 max_pan_up = 0 # Can't pan beyond top edge

188 min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge

189 self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))

190

191 return original_pan_y

192

193 def _build_page_centerlines(self, pages, dpi: float) -> list:

194 """Build list of (center_y, center_x, width) tuples for each page."""

195 PAGE_MARGIN = 50

196 PAGE_SPACING = 50

197

198 centerlines = []

199 current_y = PAGE_MARGIN

200

201 for page in pages:

202 page_width_mm, page_height_mm = page.layout.size

203 screen_page_width = page_width_mm * dpi / 25.4 * self.zoom_level

204 screen_page_height = page_height_mm * dpi / 25.4 * self.zoom_level

205

206 page_center_y = current_y + screen_page_height / 2

207 page_center_x = PAGE_MARGIN + screen_page_width / 2

208

209 centerlines.append((page_center_y, page_center_x, screen_page_width))

210 current_y += screen_page_height + PAGE_SPACING

211

212 return centerlines

213

214 def _interpolate_target_centerline(self, centerlines: list, viewport_center_y: float) -> tuple:

215 """Find target centerline by interpolating between pages based on viewport position."""

216 if not centerlines:

217 return 0, 0

218

219 # Find the page index we're at or past

220 page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)

221

222 if page_idx == 0:

223 return centerlines[0][1], centerlines[0][2]

224

225 # Interpolate between previous and current page

226 prev_y, prev_x, prev_w = centerlines[page_idx - 1]

227 curr_y, curr_x, curr_w = centerlines[page_idx]

228

229 if curr_y == prev_y:

230 return curr_x, curr_w

231

232 t = max(0, min(1, (viewport_center_y - prev_y) / (curr_y - prev_y)))

233 return prev_x + t * (curr_x - prev_x), prev_w + t * (curr_w - prev_w)

234

235 def _find_page_at_viewport_y(self, centerlines: list, viewport_center_y: float) -> int:

236 """Find index of page at or after viewport Y position."""

237 for i, (page_y, _, _) in enumerate(centerlines):

238 if viewport_center_y <= page_y:

239 return i

240 return len(centerlines) - 1 # Below all pages - use last

241

242 def _clamp_horizontal_pan(self, viewport_width: float, target_centerline_x: float, target_page_width: float):

243 """Clamp horizontal pan to keep viewport centered on target page."""

244 ideal_pan_x = viewport_width / 2 - target_centerline_x

245

246 if target_page_width > viewport_width:

247 max_deviation = (target_page_width / 2) + (viewport_width / 4)

248 else:

249 max_deviation = 100 # Small margin to avoid jitter

250

251 min_pan_x = ideal_pan_x - max_deviation

252 max_pan_x = ideal_pan_x + max_deviation

253

254 self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))

255

256 def clamp_pan_offset(self):

257 """

258 Clamp pan offset to prevent scrolling beyond content bounds.

259

260 Pan offset semantics:

261 - Positive pan_offset = content moved right/down (viewing top-left)

262 - Negative pan_offset = content moved left/up (viewing bottom-right)

263

264 For horizontal clamping, we use a centerline-based approach that interpolates

265 between page centers based on vertical scroll position. This prevents jumps

266 when zooming on pages of different widths.

267 """

268 main_window = self.window()

269 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:

270 return

271

272 viewport_width = self.width()

273 viewport_height = self.height()

274

275 # Vertical clamping (returns original pan_y for page selection)

276 original_pan_y = self._clamp_vertical_pan(viewport_height)

277

278 # Build page centerline data

279 dpi = main_window.project.working_dpi

280 centerlines = self._build_page_centerlines(main_window.project.pages, dpi)

281 if not centerlines:

282 return

283

284 # Find target centerline by interpolating based on viewport position

285 viewport_center_y = -original_pan_y + viewport_height / 2

286 target_x, target_width = self._interpolate_target_centerline(centerlines, viewport_center_y)

287

288 # Horizontal clamping

289 self._clamp_horizontal_pan(viewport_width, target_x, target_width)