diff --git a/pyPhotoAlbum/gl_imports.py b/pyPhotoAlbum/gl_imports.py index be966d5..3b4a28d 100644 --- a/pyPhotoAlbum/gl_imports.py +++ b/pyPhotoAlbum/gl_imports.py @@ -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 diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 0464374..ede094c 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -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 diff --git a/pyPhotoAlbum/mixins/operations/edit_ops.py b/pyPhotoAlbum/mixins/operations/edit_ops.py index 6dfd3c6..f91a50d 100644 --- a/pyPhotoAlbum/mixins/operations/edit_ops.py +++ b/pyPhotoAlbum/mixins/operations/edit_ops.py @@ -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, ) diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 3e36651..5c3ec15 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -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 diff --git a/pyPhotoAlbum/mixins/operations/merge_ops.py b/pyPhotoAlbum/mixins/operations/merge_ops.py index 5029b59..0123ab3 100644 --- a/pyPhotoAlbum/mixins/operations/merge_ops.py +++ b/pyPhotoAlbum/mixins/operations/merge_ops.py @@ -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): """ diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index 23ea652..1269afc 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -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() diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py index 8198fae..d4b228a 100644 --- a/pyPhotoAlbum/mixins/page_navigation.py +++ b/pyPhotoAlbum/mixins/page_navigation.py @@ -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 diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index 7fa6ff2..4c47729 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -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): diff --git a/pyPhotoAlbum/ribbon_builder.py b/pyPhotoAlbum/ribbon_builder.py index 0400667..d60f862 100644 --- a/pyPhotoAlbum/ribbon_builder.py +++ b/pyPhotoAlbum/ribbon_builder.py @@ -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 diff --git a/tests/test_view_ops_mixin.py b/tests/test_view_ops_mixin.py index ec75a11..8d4d5f9 100755 --- a/tests/test_view_ops_mixin.py +++ b/tests/test_view_ops_mixin.py @@ -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()