Fix installer
Added navigation tools
This commit is contained in:
parent
ca2b3545ee
commit
3bfe2fa654
14
install.sh
14
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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
176
pyPhotoAlbum/mixins/keyboard_navigation.py
Normal file
176
pyPhotoAlbum/mixins/keyboard_navigation.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user