diff --git a/install.sh b/install.sh index 88e4ddb..8e4abb8 100755 --- a/install.sh +++ b/install.sh @@ -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 } diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 18e519e..862475f 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -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) diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index ce43681..516dcaf 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -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 diff --git a/pyPhotoAlbum/mixins/base.py b/pyPhotoAlbum/mixins/base.py index 98e2a86..63be57a 100644 --- a/pyPhotoAlbum/mixins/base.py +++ b/pyPhotoAlbum/mixins/base.py @@ -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() diff --git a/pyPhotoAlbum/mixins/keyboard_navigation.py b/pyPhotoAlbum/mixins/keyboard_navigation.py new file mode 100644 index 0000000..848b6e0 --- /dev/null +++ b/pyPhotoAlbum/mixins/keyboard_navigation.py @@ -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) diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index 9b736bf..d886350 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -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 diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index 5a23f22..62b0475 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -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 diff --git a/pyPhotoAlbum/mixins/viewport.py b/pyPhotoAlbum/mixins/viewport.py index 1f9c8a2..2e215cc 100644 --- a/pyPhotoAlbum/mixins/viewport.py +++ b/pyPhotoAlbum/mixins/viewport.py @@ -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]))