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
« 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"""
5from pyPhotoAlbum.gl_imports import *
8class ViewportMixin:
9 """
10 Mixin providing viewport zoom and pan functionality.
12 This mixin manages the zoom level and pan offset for the OpenGL canvas,
13 including fit-to-screen calculations and OpenGL initialization.
14 """
16 def __init__(self, *args, **kwargs):
17 super().__init__(*args, **kwargs)
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
24 # Track previous viewport size to detect scrollbar-induced resizes
25 self._last_viewport_size = (0, 0)
27 def initializeGL(self):
28 """Initialize OpenGL resources"""
29 glClearColor(1.0, 1.0, 1.0, 1.0)
30 glEnable(GL_DEPTH_TEST)
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)
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
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)
57 # Update tracked viewport size
58 self._last_viewport_size = (w, h)
60 self.update()
62 # Update scrollbars when viewport size changes
63 main_window = self.window()
64 if hasattr(main_window, "update_scrollbars"):
65 main_window.update_scrollbars()
67 def _calculate_fit_to_screen_zoom(self):
68 """
69 Calculate zoom level to fit first page to screen.
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
78 window_width = self.width()
79 window_height = self.height()
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
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
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
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%
98 def _calculate_center_pan_offset(self, zoom_level):
99 """
100 Calculate pan offset to center the first page in the viewport.
102 Args:
103 zoom_level: The current zoom level to use for calculations
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]
112 window_width = self.width()
113 window_height = self.height()
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
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
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
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
134 return [x_offset, y_offset]
136 def get_content_bounds(self):
137 """
138 Calculate the total bounds of all content (pages).
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}
147 dpi = main_window.project.working_dpi
148 PAGE_MARGIN = 50
149 PAGE_SPACING = 50
151 # Calculate total dimensions
152 total_height = PAGE_MARGIN
153 max_width = 0
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
160 screen_page_width = page_width_px * self.zoom_level
161 screen_page_height = page_height_px * self.zoom_level
163 total_height += screen_page_height + PAGE_SPACING
164 max_width = max(max_width, screen_page_width)
166 total_width = max_width + PAGE_MARGIN * 2
167 total_height += PAGE_MARGIN
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 }
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"]
183 # Save original for page selection (prevents clamping from changing which page we target)
184 original_pan_y: float = self.pan_offset[1]
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]))
191 return original_pan_y
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
198 centerlines = []
199 current_y = PAGE_MARGIN
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
206 page_center_y = current_y + screen_page_height / 2
207 page_center_x = PAGE_MARGIN + screen_page_width / 2
209 centerlines.append((page_center_y, page_center_x, screen_page_width))
210 current_y += screen_page_height + PAGE_SPACING
212 return centerlines
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
219 # Find the page index we're at or past
220 page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)
222 if page_idx == 0:
223 return centerlines[0][1], centerlines[0][2]
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]
229 if curr_y == prev_y:
230 return curr_x, curr_w
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)
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
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
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
251 min_pan_x = ideal_pan_x - max_deviation
252 max_pan_x = ideal_pan_x + max_deviation
254 self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
256 def clamp_pan_offset(self):
257 """
258 Clamp pan offset to prevent scrolling beyond content bounds.
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)
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
272 viewport_width = self.width()
273 viewport_height = self.height()
275 # Vertical clamping (returns original pan_y for page selection)
276 original_pan_y = self._clamp_vertical_pan(viewport_height)
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
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)
288 # Horizontal clamping
289 self._clamp_horizontal_pan(viewport_width, target_x, target_width)