Re-organised ribbons. Fixed bug in rendering.
All checks were successful
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m7s
Tests / test (3.11) (push) Successful in 1m43s
Tests / test (3.12) (push) Successful in 1m44s
Tests / test (3.13) (push) Successful in 1m38s
Tests / test (3.14) (push) Successful in 1m17s

This commit is contained in:
Duncan Tourolle 2025-12-01 20:19:43 +01:00
parent 2a087f0c9d
commit 80d7d291f3
10 changed files with 105 additions and 92 deletions

View File

@ -62,6 +62,7 @@ try:
# Clear operations
glClear,
glClearColor,
glFlush,
GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT,
# Viewport
@ -93,7 +94,7 @@ except ImportError:
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
glLoadIdentity = glRotatef = _gl_stub
glClear = glClearColor = _gl_stub
glClear = glClearColor = glFlush = _gl_stub
glViewport = glMatrixMode = glOrtho = _gl_stub
glGetString = _gl_stub

View File

@ -53,11 +53,20 @@ class GLWidget(
def __init__(self, parent=None):
super().__init__(parent)
# Store reference to main window for accessing project
self._main_window = parent
# Initialize async loading system
self._init_async_loading()
# Initialize OpenGL
self.setFormat(self.format())
# Set up OpenGL surface format with explicit double buffering
from PyQt6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSwapInterval(1) # Enable vsync
self.setFormat(fmt)
# Force full redraws to ensure viewport updates
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
# Enable mouse tracking and drag-drop
@ -68,6 +77,20 @@ class GLWidget(
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
def window(self):
"""Override window() to return stored main_window reference.
This fixes the Qt widget hierarchy issue where window() returns None
because the GL widget is nested in container widgets.
"""
return self._main_window if hasattr(self, '_main_window') else super().window()
def update(self):
"""Override update to force immediate repaint"""
super().update()
# Force immediate processing of paint events
self.repaint()
def closeEvent(self, event):
"""Handle widget close event."""
# Cleanup async loading

View File

@ -74,7 +74,7 @@ class EditOperationsMixin:
@ribbon_action(
label="Rotate Left",
tooltip="Rotate selected element 90° counter-clockwise",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True,
)
@ -97,7 +97,7 @@ class EditOperationsMixin:
@ribbon_action(
label="Rotate Right",
tooltip="Rotate selected element 90° clockwise",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True,
)
@ -120,7 +120,7 @@ class EditOperationsMixin:
@ribbon_action(
label="Reset Rotation",
tooltip="Reset selected element rotation to 0°",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True,
)

View File

@ -586,7 +586,7 @@ class FileOperationsMixin:
x, y = element.position
element.position = (x + x_offset, y + y_offset)
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export")
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File")
def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export

View File

@ -19,8 +19,8 @@ class MergeOperationsMixin:
@ribbon_action(
label="Merge Projects",
tooltip="Merge another project file with the current project",
tab="File",
group="Import/Export",
tab="Home",
group="File",
)
def merge_projects(self):
"""

View File

@ -54,7 +54,13 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(label="Toggle Grid Snap", tooltip="Toggle snapping to grid", tab="View", group="Snapping")
@ribbon_action(
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Insert",
group="Snapping",
shortcut="Ctrl+G",
)
def toggle_grid_snap(self):
"""Toggle grid snapping"""
if not self.project:
@ -67,7 +73,13 @@ class ViewOperationsMixin:
self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}")
@ribbon_action(label="Toggle Edge Snap", tooltip="Toggle snapping to page edges", tab="View", group="Snapping")
@ribbon_action(
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Insert",
group="Snapping",
shortcut="Ctrl+E",
)
def toggle_edge_snap(self):
"""Toggle edge snapping"""
if not self.project:
@ -80,7 +92,7 @@ class ViewOperationsMixin:
self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}")
@ribbon_action(label="Toggle Guide Snap", tooltip="Toggle snapping to guides", tab="View", group="Snapping")
@ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Insert", group="Snapping")
def toggle_guide_snap(self):
"""Toggle guide snapping"""
if not self.project:
@ -93,7 +105,7 @@ class ViewOperationsMixin:
self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}")
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="View", group="Snapping")
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Insert", group="Snapping")
def toggle_show_grid(self):
"""Toggle grid visibility"""
if not self.project:
@ -106,7 +118,7 @@ class ViewOperationsMixin:
self.show_status(f"Grid {status}", 2000)
print(f"Grid {status}")
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="View", group="Snapping")
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
def toggle_snap_lines(self):
"""Toggle guide lines visibility"""
if not self.project:
@ -164,7 +176,7 @@ class ViewOperationsMixin:
print(f"Cleared {guide_count} guides")
@ribbon_action(
label="Set Grid Size...", tooltip="Configure grid spacing for snapping", tab="View", group="Snapping"
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Insert", group="Snapping"
)
def set_grid_size(self):
"""Open dialog to set grid size"""
@ -234,50 +246,3 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
# ===== Layout Tab Snapping Controls =====
# These provide easy access to snapping features during layout work
@ribbon_action(
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+G",
)
def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)"""
self.toggle_grid_snap()
@ribbon_action(
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+E",
)
def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap()
@ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Layout", group="Snapping")
def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap()
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Layout", group="Snapping")
def layout_toggle_show_grid(self):
"""Toggle grid visibility (Layout tab)"""
self.toggle_show_grid()
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping")
def layout_toggle_snap_lines(self):
"""Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines()
@ribbon_action(
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Layout", group="Snapping"
)
def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)"""
self.set_grid_size()

View File

@ -70,15 +70,23 @@ class PageNavigationMixin:
Returns:
List of tuples (page_type, page_or_ghost_data, y_offset)
"""
main_window = self.window()
if not hasattr(main_window, "project"):
# Use stored reference to main window
main_window = getattr(self, '_main_window', None)
if main_window is None:
main_window = self.window()
try:
project = main_window.project
if not project:
return []
except (AttributeError, TypeError):
return []
dpi = main_window.project.working_dpi
dpi = project.working_dpi
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
# Convert to pixels at working DPI
spacing_mm = main_window.project.page_spacing_mm
spacing_mm = project.page_spacing_mm
spacing_px = spacing_mm * dpi / 25.4
# Start with a small top margin (5mm)
@ -89,7 +97,7 @@ class PageNavigationMixin:
current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
# First, render cover if it exists
for page in main_window.project.pages:
for page in project.pages:
if page.is_cover:
result.append(("page", page, current_y))
@ -102,7 +110,7 @@ class PageNavigationMixin:
break # Only one cover allowed
# Get page layout with ghosts from project (this excludes cover)
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
layout_with_ghosts = project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == "page":
@ -119,7 +127,7 @@ class PageNavigationMixin:
elif page_type == "ghost":
# Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm
page_size_mm = project.page_size_mm
from pyPhotoAlbum.models import GhostPageData
# Create ghost page data with correct size

View File

@ -25,8 +25,23 @@ class RenderingMixin:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glLoadIdentity()
main_window = self.window()
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
# Use stored reference to main window
main_window = getattr(self, '_main_window', None)
if main_window is None:
# Fallback to window() if _main_window not set
main_window = self.window()
if main_window is None:
return
try:
project = main_window.project
if not project:
return
if not project.pages:
return
except AttributeError:
# Project not yet initialized
return
# Set initial zoom and center the page if not done yet
@ -36,11 +51,10 @@ class RenderingMixin:
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()
if hasattr(self, '_main_window') and hasattr(self._main_window, "update_scrollbars"):
self._main_window.update_scrollbars()
dpi = main_window.project.working_dpi
dpi = project.working_dpi
# Calculate page positions with ghosts
page_positions = self._get_page_positions()
@ -52,6 +66,7 @@ class RenderingMixin:
PAGE_MARGIN = 50
# Render all pages
pages_rendered = 0
for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info
@ -76,8 +91,9 @@ class RenderingMixin:
renderer.begin_render()
# Pass widget reference for async loading
page.layout._parent_widget = self
page.layout.render(dpi=dpi, project=main_window.project)
page.layout.render(dpi=dpi, project=project)
renderer.end_render()
pages_rendered += 1
elif page_type == "ghost":
ghost = page_or_ghost
@ -109,7 +125,8 @@ class RenderingMixin:
for element in self.selected_elements:
self._draw_selection_handles(element)
# Render text overlays
# Render text overlays using QPainter
# Qt will handle OpenGL/QPainter coordination automatically
self._render_text_overlays()
def _draw_selection_handles(self, element):

View File

@ -74,7 +74,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
ribbon_config = {}
# Define tab order (tabs will appear in this order)
tab_order = ["Home", "Insert", "Layout", "Arrange", "View", "Export"]
tab_order = ["Home", "Insert", "Layout", "Arrange", "View"]
# Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys())
@ -90,11 +90,10 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
# Define group order per tab (if needed)
group_orders = {
"Home": ["File", "Edit"],
"Insert": ["Media"],
"Layout": ["Navigation", "Page", "Templates"],
"Arrange": ["Align", "Size", "Distribute"],
"View": ["Zoom"],
"Export": ["Export"],
"Insert": ["Media", "Snapping"],
"Layout": ["Page", "Templates"],
"Arrange": ["Align", "Distribute", "Size", "Order", "Transform"],
"View": ["Zoom", "Guides"],
}
# Get the group order for this tab, or use alphabetical

View File

@ -373,9 +373,9 @@ class TestLayoutTabDelegation:
window.project.snap_to_grid = False
window.layout_toggle_grid_snap()
window.toggle_grid_snap()
# Should delegate to toggle_grid_snap
# Should toggle snap_to_grid
assert window.project.snap_to_grid is True
assert window._update_view_called
@ -385,7 +385,7 @@ class TestLayoutTabDelegation:
window.project.snap_to_edges = False
window.layout_toggle_edge_snap()
window.toggle_edge_snap()
assert window.project.snap_to_edges is True
assert window._update_view_called
@ -396,7 +396,7 @@ class TestLayoutTabDelegation:
window.project.snap_to_guides = False
window.layout_toggle_guide_snap()
window.toggle_guide_snap()
assert window.project.snap_to_guides is True
assert window._update_view_called
@ -407,13 +407,13 @@ class TestLayoutTabDelegation:
window.project.show_snap_lines = False
window.layout_toggle_snap_lines()
window.toggle_snap_lines()
assert window.project.show_snap_lines is True
assert window._update_view_called
def test_layout_set_grid_size_delegates(self, qtbot):
"""Test layout tab grid size delegates to main method"""
"""Test grid size dialog works"""
window = TestViewWindow()
qtbot.addWidget(window)
@ -422,12 +422,12 @@ class TestLayoutTabDelegation:
page.layout = layout
window._current_page = page
# Mock dialog to verify delegation
# Mock dialog to verify it runs
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog):
window.layout_set_grid_size()
window.set_grid_size()
# Verify method was called (dialog creation attempted)
# Verify dialog was shown
mock_dialog.exec.assert_called_once()