Fixed bug where zooming could cause viewport to moce to page 1

This commit is contained in:
Duncan Tourolle 2025-11-23 21:32:58 +01:00
parent 6755549dfd
commit 2fc99a3292
4 changed files with 164 additions and 17 deletions

View File

@ -173,12 +173,16 @@ class MainWindow(
# Track scrollbar updates to prevent feedback loops
self._updating_scrollbars = False
# Track scrollbar visibility changes to prevent resize-triggered recentering
self._updating_scrollbar_visibility = False
def _on_vertical_scroll(self, value):
"""Handle vertical scrollbar changes"""
if not self._updating_scrollbars:
# Invert scrollbar value to pan offset (scrolling down = negative pan)
old_pan_y = self._gl_widget.pan_offset[1]
self._gl_widget.pan_offset[1] = -value
print(f"SCROLLBAR V: changed pan_y from {old_pan_y:.1f} to {self._gl_widget.pan_offset[1]:.1f} (scrollbar value={value})")
self._gl_widget.update()
def _on_horizontal_scroll(self, value):
@ -190,8 +194,16 @@ class MainWindow(
def update_scrollbars(self):
"""Update scrollbar positions and ranges based on current content and pan offset"""
print(f"UPDATE_SCROLLBARS: START - pan_offset = [{self._gl_widget.pan_offset[0]:.1f}, {self._gl_widget.pan_offset[1]:.1f}]")
self._updating_scrollbars = True
# Block signals to prevent feedback loop
signals_were_blocked_v = self._v_scrollbar.signalsBlocked()
signals_were_blocked_h = self._h_scrollbar.signalsBlocked()
self._v_scrollbar.blockSignals(True)
self._h_scrollbar.blockSignals(True)
print(f"UPDATE_SCROLLBARS: Blocked signals (v was {signals_were_blocked_v}, h was {signals_were_blocked_h})")
# Get content bounds
bounds = self._gl_widget.get_content_bounds()
viewport_width = self._gl_widget.width()
@ -200,18 +212,24 @@ class MainWindow(
content_height = bounds['height']
content_width = bounds['width']
print(f"UPDATE_SCROLLBARS: content={content_width:.0f}x{content_height:.0f}, viewport={viewport_width}x{viewport_height}")
# Vertical scrollbar
# Scrollbar value 0 = top of content
# Scrollbar value max = bottom of content
# Pan offset is inverted: positive pan = content moved down = view at top
# negative pan = content moved up = view at bottom
v_range = int(max(0, content_height - viewport_height))
v_value = int(max(0, min(v_range, -self._gl_widget.pan_offset[1])))
print(f"UPDATE_SCROLLBARS: Setting v_scrollbar range=0-{v_range}, value={v_value} (from pan_y={self._gl_widget.pan_offset[1]:.1f})")
self._v_scrollbar.setRange(0, v_range)
self._v_scrollbar.setPageStep(int(viewport_height))
# Invert pan_offset for scrollbar position
self._v_scrollbar.setValue(int(max(0, min(v_range, -self._gl_widget.pan_offset[1]))))
self._v_scrollbar.setValue(v_value)
# Show/hide vertical scrollbar based on whether scrolling is needed
# Set flag to prevent resizeGL from recentering when scrollbar visibility changes
self._updating_scrollbar_visibility = True
self._v_scrollbar.setVisible(v_range > 0)
# Horizontal scrollbar
@ -223,8 +241,15 @@ class MainWindow(
# Show/hide horizontal scrollbar based on whether scrolling is needed
self._h_scrollbar.setVisible(h_range > 0)
self._updating_scrollbar_visibility = False
# Unblock signals
self._v_scrollbar.blockSignals(False)
self._h_scrollbar.blockSignals(False)
print(f"UPDATE_SCROLLBARS: Unblocked signals")
self._updating_scrollbars = False
print(f"UPDATE_SCROLLBARS: END - pan_offset = [{self._gl_widget.pan_offset[0]:.1f}, {self._gl_widget.pan_offset[1]:.1f}]")
def _register_shortcuts(self):
"""Register keyboard shortcuts from decorated methods"""

View File

@ -280,6 +280,7 @@ class MouseInteractionMixin:
new_zoom = self.zoom_level * zoom_factor
if 0.1 <= new_zoom <= 5.0:
old_zoom = self.zoom_level
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]

View File

@ -21,6 +21,9 @@ class ViewportMixin:
self.pan_offset = [0, 0]
self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom
# Track previous viewport size to detect scrollbar-induced resizes
self._last_viewport_size = (0, 0)
def initializeGL(self):
"""Initialize OpenGL resources"""
glClearColor(1.0, 1.0, 1.0, 1.0)
@ -34,11 +37,26 @@ class ViewportMixin:
glOrtho(0, w, h, 0, -1, 1)
glMatrixMode(GL_MODELVIEW)
# Detect if this is a small resize (likely scrollbar visibility change)
# Scrollbars are typically 14-20 pixels wide
last_w, last_h = self._last_viewport_size
width_change = abs(w - last_w)
height_change = abs(h - last_h)
is_small_resize = width_change <= 20 and height_change <= 20
is_first_resize = last_w == 0 and last_h == 0
# Recalculate centering if we have a project loaded
if self.initial_zoom_set:
# Recenter on:
# 1. First resize (initial setup)
# 2. Large resizes (window resize, NOT scrollbar changes)
# Don't recenter on small resizes (scrollbar visibility changes during zoom)
if self.initial_zoom_set and (is_first_resize or not is_small_resize):
# Maintain current zoom level, just recenter
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
# Update tracked viewport size
self._last_viewport_size = (w, h)
self.update()
# Update scrollbars when viewport size changes
@ -164,27 +182,130 @@ class ViewportMixin:
Pan offset semantics:
- Positive pan_offset = content moved right/down (viewing top-left)
- Negative pan_offset = content moved left/up (viewing bottom-right)
For horizontal clamping, we use a centerline-based approach that interpolates
between page centers based on vertical scroll position. This prevents jumps
when zooming on pages of different widths.
"""
bounds = self.get_content_bounds()
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return
viewport_width = self.width()
viewport_height = self.height()
content_width = bounds['width']
content_height = bounds['height']
# Only clamp if content is larger than viewport
# If content is smaller, allow any pan offset (for centering)
# Horizontal clamping
if content_width > viewport_width:
# Content is wider than viewport - restrict panning
max_pan_left = 0 # Can't pan beyond left edge
min_pan_left = -(content_width - viewport_width) # Can't pan beyond right edge
self.pan_offset[0] = max(min_pan_left, min(max_pan_left, self.pan_offset[0]))
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
PAGE_SPACING = 50
# Vertical clamping
bounds = self.get_content_bounds()
content_height = bounds['height']
# Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on
original_pan_y = self.pan_offset[1]
if content_height > viewport_height:
# Content is taller than viewport - restrict panning
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
# Don't force centering when content fits - preserve scroll position
# This prevents jumping when zooming in/out across the content_height == viewport_height boundary
# Horizontal clamping - centerline-based approach
# Calculate the centerline position for each page and interpolate
# Build list of page centerlines and their Y positions
page_centerlines = []
current_y = PAGE_MARGIN
for page in main_window.project.pages:
page_width_mm, page_height_mm = page.layout.size
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
screen_page_width = page_width_px * self.zoom_level
screen_page_height = page_height_px * self.zoom_level
# Calculate page center Y position (in world coordinates)
page_center_y = current_y + screen_page_height / 2
# Calculate the centerline X position (center of the page)
# Pages are left-aligned at PAGE_MARGIN, so center is at PAGE_MARGIN + width/2
page_center_x = PAGE_MARGIN + screen_page_width / 2
page_centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
if not page_centerlines:
return
# Determine current viewport center Y in world coordinates using ORIGINAL pan_y
# This prevents vertical clamping from changing which page we're targeting
# viewport_center_y in screen coords = viewport_height / 2
# Convert to world coords: world_y = (screen_y - pan_offset[1]) / zoom_level
# But we want screen position, so we use pan_offset directly
viewport_center_y_world = -original_pan_y + viewport_height / 2
# Find which pages we're between and interpolate
target_centerline_x = page_centerlines[0][1] # Default to first page
target_page_width = page_centerlines[0][2]
selected_page_index = 0
for i in range(len(page_centerlines)):
page_y, page_x, page_w = page_centerlines[i]
if viewport_center_y_world <= page_y:
# We're above or at this page's center
if i == 0:
# First page
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = 0
else:
# Interpolate between previous and current page
prev_y, prev_x, prev_w = page_centerlines[i - 1]
# Linear interpolation factor
if page_y != prev_y:
t = (viewport_center_y_world - prev_y) / (page_y - prev_y)
t = max(0, min(1, t)) # Clamp to [0, 1]
target_centerline_x = prev_x + t * (page_x - prev_x)
target_page_width = prev_w + t * (page_w - prev_w)
selected_page_index = i - 1 if t < 0.5 else i
else:
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = i
break
else:
# We're below all pages - use last page
target_centerline_x = page_centerlines[-1][1]
target_page_width = page_centerlines[-1][2]
selected_page_index = len(page_centerlines) - 1
# Horizontal clamping to keep viewport reasonably centered on the page
# The centerline should ideally be at viewport_width / 2
ideal_pan_x = viewport_width / 2 - target_centerline_x
# Calculate how far we need to allow panning to see the full width of the page
# If page is wider than viewport, allow panning to see left and right edges
# If page is narrower, keep it centered
if target_page_width > viewport_width:
# Page wider than viewport - allow panning to see edges plus some margin
# Allow user to pan to see any part of the page, with reasonable overshoot
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
# Page narrower than viewport - keep centered with small margin for stability
max_deviation = 100 # Small margin to avoid jitter
# Calculate bounds
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
old_pan_x = self.pan_offset[0]
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))

View File

@ -204,7 +204,7 @@ class ImageData(BaseLayoutElement):
for path in possible_paths:
if os.path.exists(path):
image_full_path = path
print(f"ImageData: Resolved {self.image_path}{path}")
# print(f"ImageData: Resolved {self.image_path} → {path}")
break
else:
print(f"ImageData: Could not resolve path: {self.image_path}")
@ -399,7 +399,7 @@ class ImageData(BaseLayoutElement):
# Update metadata for future renders - always update to reflect rotated dimensions
self.image_dimensions = (pil_image.width, pil_image.height)
print(f"ImageData: Async loaded texture for {self.image_path}")
# print(f"ImageData: Async loaded texture for {self.image_path}")
except Exception as e:
print(f"ImageData: Error creating texture from async loaded image: {e}")