Coverage for pyPhotoAlbum/mixins/viewport.py: 97%

143 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Viewport mixin for GLWidget - handles zoom and pan 

3""" 

4 

5from pyPhotoAlbum.gl_imports import * 

6 

7 

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