Fix installer
Some checks failed
Python CI / test (push) Successful in 1m19s
Lint / lint (push) Successful in 1m24s
Tests / test (3.10) (push) Failing after 1m2s
Tests / test (3.11) (push) Failing after 58s
Tests / test (3.9) (push) Failing after 59s

Added navigation tools
This commit is contained in:
Duncan Tourolle 2025-11-22 08:50:36 +01:00
parent ca2b3545ee
commit 3bfe2fa654
8 changed files with 425 additions and 8 deletions

View File

@ -72,6 +72,11 @@ install_dependencies() {
esac
}
# Check if running in a virtual environment
in_virtualenv() {
[ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_DEFAULT_ENV" ]
}
# Install Python package
install_package() {
local install_mode=$1
@ -80,8 +85,13 @@ install_package() {
print_info "Installing pyPhotoAlbum system-wide..."
sudo pip install .
else
print_info "Installing pyPhotoAlbum for current user..."
pip install --user .
if in_virtualenv; then
print_info "Installing pyPhotoAlbum in virtual environment..."
pip install .
else
print_info "Installing pyPhotoAlbum for current user..."
pip install --user .
fi
fi
}

View File

@ -17,6 +17,7 @@ from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
class GLWidget(
@ -30,6 +31,7 @@ class GLWidget(
ElementSelectionMixin,
MouseInteractionMixin,
UndoableInteractionMixin,
KeyboardNavigationMixin,
QOpenGLWidget
):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
@ -60,6 +62,10 @@ class GLWidget(
self.setMouseTracking(True)
self.setAcceptDrops(True)
# Enable keyboard focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
def closeEvent(self, event):
"""Handle widget close event."""
# Cleanup async loading
@ -93,5 +99,26 @@ class GLWidget(
else:
super().keyPressEvent(event)
elif event.key() == Qt.Key.Key_PageDown:
# Navigate to next page
self._navigate_to_next_page()
event.accept()
elif event.key() == Qt.Key.Key_PageUp:
# Navigate to previous page
self._navigate_to_previous_page()
event.accept()
elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
# Arrow key handling
if self.selected_elements:
# Move selected elements
self._move_selected_elements_with_arrow_keys(event.key())
event.accept()
else:
# Move viewport
self._move_viewport_with_arrow_keys(event.key())
event.accept()
else:
super().keyPressEvent(event)

View File

@ -8,10 +8,10 @@ This version uses the mixin architecture with auto-generated ribbon configuratio
import sys
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget,
QStatusBar
QApplication, QMainWindow, QVBoxLayout, QWidget,
QStatusBar, QScrollBar, QHBoxLayout
)
from PyQt6.QtCore import Qt
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QIcon
from pyPhotoAlbum.project import Project
@ -113,10 +113,40 @@ class MainWindow(
# Create ribbon with auto-generated config
self.ribbon = RibbonWidget(self, ribbon_config)
main_layout.addWidget(self.ribbon, 0)
# Create canvas area with GL widget and scroll bars
canvas_widget = QWidget()
canvas_layout = QVBoxLayout()
canvas_layout.setContentsMargins(0, 0, 0, 0)
canvas_layout.setSpacing(0)
# Top row: GL widget + vertical scrollbar
top_layout = QHBoxLayout()
top_layout.setContentsMargins(0, 0, 0, 0)
top_layout.setSpacing(0)
# Create OpenGL widget
self._gl_widget = GLWidget(self)
main_layout.addWidget(self._gl_widget, 1)
top_layout.addWidget(self._gl_widget, 1)
# Vertical scrollbar
self._v_scrollbar = QScrollBar(Qt.Orientation.Vertical)
self._v_scrollbar.setRange(-10000, 10000)
self._v_scrollbar.setValue(0)
self._v_scrollbar.valueChanged.connect(self._on_vertical_scroll)
top_layout.addWidget(self._v_scrollbar, 0)
canvas_layout.addLayout(top_layout, 1)
# Bottom row: horizontal scrollbar
self._h_scrollbar = QScrollBar(Qt.Orientation.Horizontal)
self._h_scrollbar.setRange(-10000, 10000)
self._h_scrollbar.setValue(0)
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
canvas_layout.addWidget(self._h_scrollbar, 0)
canvas_widget.setLayout(canvas_layout)
main_layout.addWidget(canvas_widget, 1)
self.setCentralWidget(main_widget)
@ -126,7 +156,62 @@ class MainWindow(
# Register keyboard shortcuts
self._register_shortcuts()
# Track scrollbar updates to prevent feedback loops
self._updating_scrollbars = 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)
self._gl_widget.pan_offset[1] = -value
self._gl_widget.update()
def _on_horizontal_scroll(self, value):
"""Handle horizontal scrollbar changes"""
if not self._updating_scrollbars:
# Invert scrollbar value to pan offset (scrolling right = negative pan)
self._gl_widget.pan_offset[0] = -value
self._gl_widget.update()
def update_scrollbars(self):
"""Update scrollbar positions and ranges based on current content and pan offset"""
self._updating_scrollbars = True
# Get content bounds
bounds = self._gl_widget.get_content_bounds()
viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height()
content_height = bounds['height']
content_width = bounds['width']
# 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))
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]))))
# Show/hide vertical scrollbar based on whether scrolling is needed
self._v_scrollbar.setVisible(v_range > 0)
# Horizontal scrollbar
h_range = int(max(0, content_width - viewport_width))
self._h_scrollbar.setRange(0, h_range)
self._h_scrollbar.setPageStep(int(viewport_width))
# Invert pan_offset for scrollbar position
self._h_scrollbar.setValue(int(max(0, min(h_range, -self._gl_widget.pan_offset[0]))))
# Show/hide horizontal scrollbar based on whether scrolling is needed
self._h_scrollbar.setVisible(h_range > 0)
self._updating_scrollbars = False
def _register_shortcuts(self):
"""Register keyboard shortcuts from decorated methods"""
from PyQt6.QtGui import QShortcut, QKeySequence

View File

@ -209,3 +209,7 @@ class ApplicationStateMixin:
"""Trigger GL widget update to refresh the view"""
if self.gl_widget:
self.gl_widget.update()
# Update scrollbars to reflect new content
if hasattr(self, 'update_scrollbars'):
self.update_scrollbars()

View File

@ -0,0 +1,176 @@
"""
Keyboard navigation mixin for GLWidget - handles keyboard-based navigation
"""
from PyQt6.QtCore import Qt
class KeyboardNavigationMixin:
"""
Mixin providing keyboard navigation functionality.
This mixin handles Page Up/Down navigation between pages,
arrow key viewport movement, and arrow key element movement.
"""
def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
if current_index < len(main_window.project.pages) - 1:
next_page = main_window.project.pages[current_index + 1]
self._scroll_to_page(next_page, current_index + 1)
if hasattr(main_window, 'show_status'):
page_name = main_window.project.get_page_display_name(next_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
if current_index > 0:
prev_page = main_window.project.pages[current_index - 1]
self._scroll_to_page(prev_page, current_index - 1)
if hasattr(main_window, 'show_status'):
page_name = main_window.project.get_page_display_name(prev_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
def _scroll_to_page(self, page, page_index):
"""
Scroll the viewport to center a specific page.
Args:
page: The page to scroll to
page_index: The index of the page in the project
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
return
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
PAGE_SPACING = 50
# Calculate the Y offset for this page
y_offset = PAGE_MARGIN
for i in range(page_index):
prev_page = main_window.project.pages[i]
prev_height_mm = prev_page.layout.size[1]
prev_height_px = prev_height_mm * dpi / 25.4
y_offset += prev_height_px * self.zoom_level + PAGE_SPACING
# Get page height
page_height_mm = page.layout.size[1]
page_height_px = page_height_mm * dpi / 25.4
screen_page_height = page_height_px * self.zoom_level
# Center the page in the viewport
viewport_height = self.height()
target_pan_y = viewport_height / 2 - y_offset - screen_page_height / 2
self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key):
"""
Move the viewport using arrow keys when no objects are selected.
Args:
key: The Qt key code (Up, Down, Left, Right)
"""
# Movement amount in pixels
move_amount = 50
if key == Qt.Key.Key_Up:
self.pan_offset[1] += move_amount
elif key == Qt.Key.Key_Down:
self.pan_offset[1] -= move_amount
elif key == Qt.Key.Key_Left:
self.pan_offset[0] += move_amount
elif key == Qt.Key.Key_Right:
self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key):
"""
Move selected elements using arrow keys.
Args:
key: The Qt key code (Up, Down, Left, Right)
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
return
# Movement amount in mm
move_amount_mm = 1.0 # 1mm per keypress
# Calculate movement delta
dx, dy = 0, 0
if key == Qt.Key.Key_Up:
dy = -move_amount_mm
elif key == Qt.Key.Key_Down:
dy = move_amount_mm
elif key == Qt.Key.Key_Left:
dx = -move_amount_mm
elif key == Qt.Key.Key_Right:
dx = move_amount_mm
# Move all selected elements
for element in self.selected_elements:
current_x, current_y = element.position
new_x = current_x + dx
new_y = current_y + dy
# Apply snapping if element has a parent page
if hasattr(element, '_parent_page') and element._parent_page:
page = element._parent_page
snap_sys = page.layout.snapping_system
page_size = page.layout.size
dpi = main_window.project.working_dpi
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project
)
element.position = snapped_pos
else:
element.position = (new_x, new_y)
self.update()
if hasattr(main_window, 'show_status'):
count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000)

View File

@ -25,6 +25,9 @@ class MouseInteractionMixin:
def mousePressEvent(self, event):
"""Handle mouse press events"""
# Ensure widget has focus for keyboard events
self.setFocus()
if event.button() == Qt.MouseButton.LeftButton:
x, y = event.position().x(), event.position().y()
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
@ -123,8 +126,18 @@ class MouseInteractionMixin:
self.pan_offset[0] += dx
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
return
if not self.is_dragging or not self.drag_start_pos:
@ -272,17 +285,35 @@ class MouseInteractionMixin:
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.update()
main_window = self.window()
if hasattr(main_window, 'status_bar'):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
else:
# Regular wheel: Vertical scroll
scroll_amount = delta * 0.5
self.pan_offset[1] += scroll_amount
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
def _edit_text_element(self, text_element):
"""Open dialog to edit text element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog

View File

@ -33,6 +33,11 @@ class RenderingMixin:
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
self.initial_zoom_set = True
# Update scrollbars now that we have content bounds
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
dpi = main_window.project.working_dpi
# Calculate page positions with ghosts

View File

@ -41,6 +41,11 @@ class ViewportMixin:
self.update()
# Update scrollbars when viewport size changes
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self):
"""
Calculate zoom level to fit first page to screen.
@ -109,3 +114,77 @@ class ViewportMixin:
y_offset = (window_height - screen_page_height) / 2
return [x_offset, y_offset]
def get_content_bounds(self):
"""
Calculate the total bounds of all content (pages).
Returns:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600}
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
PAGE_SPACING = 50
# Calculate total dimensions
total_height = PAGE_MARGIN
max_width = 0
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
total_height += screen_page_height + PAGE_SPACING
max_width = max(max_width, screen_page_width)
total_width = max_width + PAGE_MARGIN * 2
total_height += PAGE_MARGIN
return {
'min_x': 0,
'max_x': total_width,
'min_y': 0,
'max_y': total_height,
'width': total_width,
'height': total_height
}
def clamp_pan_offset(self):
"""
Clamp pan offset to prevent scrolling beyond content bounds.
Pan offset semantics:
- Positive pan_offset = content moved right/down (viewing top-left)
- Negative pan_offset = content moved left/up (viewing bottom-right)
"""
bounds = self.get_content_bounds()
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]))
# Vertical clamping
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]))