diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 32c344b..0000000 --- a/examples/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# pyPhotoAlbum Examples - -This directory contains working examples demonstrating various features of pyPhotoAlbum. - -## Available Examples - -### basic_usage.py -Demonstrates the fundamentals of creating a photo album project: -- Creating a project -- Adding pages with images -- Working with text boxes -- Saving and loading projects -- Asset management - -Run: -```bash -python basic_usage.py -``` - -### template_example.py -Shows how to work with the template system: -- Creating custom templates -- Applying templates to pages -- Using built-in templates -- Template scaling modes - -Run: -```bash -python template_example.py -``` - -### generate_screenshots.py -Script to generate documentation screenshots programmatically: -- Creates example projects -- Captures screenshots for documentation -- Demonstrates various layouts - -Run: -```bash -python generate_screenshots.py -``` - -This creates screenshots in `examples/screenshots/` directory. - -## Requirements - -Make sure you have pyPhotoAlbum installed: - -```bash -# From the project root -pip install -e . -``` - -## Sample Images - -The examples use placeholder images. To use your own: - -1. Create an `images/` directory in the examples folder -2. Add your sample images -3. Update the image paths in the example scripts - -## Output - -Examples will create: -- `examples/output/` - Generated project files (.ppz) -- `examples/screenshots/` - Documentation screenshots -- `examples/pdfs/` - Exported PDFs - -These directories are created automatically when you run the examples. - -## Notes - -- The examples use realistic page sizes (A4, square formats) -- DPI settings match typical print requirements (300 DPI) -- All examples include error handling and cleanup -- Examples demonstrate both programmatic and template-based workflows - -## Regenerating Documentation Screenshots - -To update screenshots for the documentation: - -1. Run `python generate_screenshots.py` -2. Review generated images in `screenshots/` -3. Copy needed screenshots to documentation - -The script generates: -- UI screenshots -- Layout examples -- Template demonstrations -- Feature showcases diff --git a/examples/basic_usage.py b/examples/basic_usage.py deleted file mode 100644 index fc3c261..0000000 --- a/examples/basic_usage.py +++ /dev/null @@ -1,426 +0,0 @@ -#!/usr/bin/env python3 -""" -Basic Usage Example for pyPhotoAlbum - -This example demonstrates: -- Creating a new project -- Adding pages with images and text -- Working with the asset manager -- Saving and loading projects -- Basic element manipulation - -Based on unit test examples from the pyPhotoAlbum test suite. -""" - -import os -import sys -import tempfile -from pathlib import Path - -# Add parent directory to path to import pyPhotoAlbum -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyPhotoAlbum.project import Project, Page -from pyPhotoAlbum.page_layout import PageLayout -from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData -from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip, get_project_info -from pyPhotoAlbum.pdf_exporter import PDFExporter - - -def create_sample_image(path: str, color: str = 'blue', size: tuple = (400, 300)): - """Create a sample image for testing""" - try: - from PIL import Image, ImageDraw, ImageFont - - img = Image.new('RGB', size, color=color) - draw = ImageDraw.Draw(img) - - # Draw a border - border_width = 10 - draw.rectangle( - [(border_width, border_width), (size[0]-border_width, size[1]-border_width)], - outline='white', - width=5 - ) - - # Add some text - text = f"{color.upper()}\n{size[0]}x{size[1]}" - try: - # Try to use a nice font - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36) - except: - # Fallback to default - font = ImageFont.load_default() - - # Calculate text position (center) - bbox = draw.textbbox((0, 0), text, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - x = (size[0] - text_width) // 2 - y = (size[1] - text_height) // 2 - - draw.text((x, y), text, fill='white', font=font) - - img.save(path) - print(f"Created sample image: {path}") - return True - except Exception as e: - print(f"Could not create sample image: {e}") - return False - - -def example_1_create_basic_project(): - """ - Example 1: Create a basic project with one page and an image - - This demonstrates the fundamental workflow based on test_project.py - """ - print("\n" + "="*60) - print("Example 1: Creating a Basic Project") - print("="*60) - - # Create output directory - output_dir = Path(__file__).parent / "output" - output_dir.mkdir(exist_ok=True) - - # Create a temporary directory for the project - temp_dir = tempfile.mkdtemp(prefix="photo_album_") - project_folder = os.path.join(temp_dir, "my_album") - - print(f"\nProject folder: {project_folder}") - - # Create a new project (from test_project.py) - project = Project(name="My First Album", folder_path=project_folder) - project.page_size_mm = (210, 297) # A4 size - project.working_dpi = 300 - project.export_dpi = 300 - - print(f"Created project: {project.name}") - print(f"Page size: {project.page_size_mm[0]}mm x {project.page_size_mm[1]}mm") - print(f"DPI: {project.working_dpi}") - - # Create a sample image - image_path = output_dir / "sample_photo.jpg" - create_sample_image(str(image_path), color='blue', size=(800, 600)) - - # Import the image into the project (from test_project_serialization.py) - imported_path = project.asset_manager.import_asset(str(image_path)) - print(f"\nImported asset: {imported_path}") - - # Create a page layout (from test_project.py) - layout = PageLayout(width=210, height=297) - - # Add an image element (from test_models.py) - image = ImageData( - image_path=imported_path, - x=10.0, - y=10.0, - width=190.0, - height=140.0, - rotation=0, - z_index=0 - ) - layout.add_element(image) - print(f"Added image at position ({image.position[0]}, {image.position[1]})") - print(f"Image size: {image.size[0]}mm x {image.size[1]}mm") - - # Add a text box (from test_models.py) - textbox = TextBoxData( - text_content="My First Photo Album", - font_settings={"family": "Arial", "size": 24, "color": (0, 0, 0)}, - alignment="center", - x=10.0, - y=160.0, - width=190.0, - height=30.0 - ) - layout.add_element(textbox) - print(f"Added text box: '{textbox.text_content}'") - - # Create a page and add it to the project - page = Page(layout=layout, page_number=1) - project.add_page(page) - print(f"\nAdded page {page.page_number} to project") - print(f"Total pages: {len(project.pages)}") - print(f"Total elements on page: {len(page.layout.elements)}") - - # Save the project (from test_project_serialization.py) - output_path = output_dir / "basic_project.ppz" - print(f"\nSaving project to: {output_path}") - success, error = save_to_zip(project, str(output_path)) - - if success: - print("Project saved successfully!") - - # Get project info without loading (from test_project_serialization.py) - info = get_project_info(str(output_path)) - if info: - print(f"\nProject Info:") - print(f" Name: {info['name']}") - print(f" Pages: {info['page_count']}") - print(f" Version: {info['version']}") - print(f" Working DPI: {info['working_dpi']}") - else: - print(f"Error saving project: {error}") - - return str(output_path) - - -def example_2_load_and_modify_project(project_path: str): - """ - Example 2: Load an existing project and add more pages - - Based on test_project_serialization.py - """ - print("\n" + "="*60) - print("Example 2: Loading and Modifying a Project") - print("="*60) - - # Load the project (from test_project_serialization.py) - print(f"\nLoading project from: {project_path}") - loaded_project, error = load_from_zip(project_path) - - if not loaded_project: - print(f"Error loading project: {error}") - return - - print(f"Loaded project: {loaded_project.name}") - print(f"Pages: {len(loaded_project.pages)}") - - # Create output directory - output_dir = Path(__file__).parent / "output" - - # Create more sample images - for i in range(2, 4): - image_path = output_dir / f"sample_photo_{i}.jpg" - colors = ['red', 'green'] - create_sample_image(str(image_path), color=colors[i-2], size=(600, 800)) - - # Import the image - imported_path = loaded_project.asset_manager.import_asset(str(image_path)) - - # Create a new page with the image (from test_project.py) - layout = PageLayout(width=210, height=297) - image = ImageData( - image_path=imported_path, - x=20.0 + i*5, - y=20.0 + i*5, - width=170.0, - height=230.0 - ) - layout.add_element(image) - - # Add caption - caption = TextBoxData( - text_content=f"Page {i}", - font_settings={"family": "Arial", "size": 18, "color": (0, 0, 0)}, - alignment="center", - x=20.0 + i*5, - y=260.0, - width=170.0, - height=25.0 - ) - layout.add_element(caption) - - page = Page(layout=layout, page_number=i) - loaded_project.add_page(page) - print(f"Added page {i} with {len(page.layout.elements)} elements") - - # Save the modified project - modified_path = output_dir / "modified_project.ppz" - print(f"\nSaving modified project to: {modified_path}") - success, error = save_to_zip(loaded_project, str(modified_path)) - - if success: - print("Modified project saved successfully!") - print(f"Total pages: {len(loaded_project.pages)}") - else: - print(f"Error saving: {error}") - - return str(modified_path) - - -def example_3_serialization_roundtrip(): - """ - Example 3: Demonstrate serialization/deserialization - - Based on test_models.py serialization tests - """ - print("\n" + "="*60) - print("Example 3: Serialization Round-Trip") - print("="*60) - - # Create an image element (from test_models.py) - print("\n1. Creating ImageData element...") - original_image = ImageData( - image_path="test.jpg", - x=50.0, - y=60.0, - width=300.0, - height=200.0, - rotation=15.0, - z_index=2, - crop_info=(0.1, 0.1, 0.9, 0.9) - ) - - print(f" Position: {original_image.position}") - print(f" Size: {original_image.size}") - print(f" Rotation: {original_image.rotation}°") - print(f" Z-index: {original_image.z_index}") - - # Serialize (from test_models.py) - print("\n2. Serializing to dictionary...") - data = original_image.serialize() - print(f" Serialized data keys: {list(data.keys())}") - print(f" Type: {data['type']}") - - # Deserialize (from test_models.py) - print("\n3. Deserializing from dictionary...") - restored_image = ImageData() - restored_image.deserialize(data) - - print(f" Position: {restored_image.position}") - print(f" Size: {restored_image.size}") - print(f" Rotation: {restored_image.rotation}°") - - # Verify round-trip - print("\n4. Verifying round-trip...") - assert restored_image.position == original_image.position - assert restored_image.size == original_image.size - assert restored_image.rotation == original_image.rotation - assert restored_image.z_index == original_image.z_index - assert restored_image.crop_info == original_image.crop_info - print(" Round-trip successful!") - - # Do the same for TextBoxData (from test_models.py) - print("\n5. Testing TextBoxData serialization...") - font_settings = {"family": "Georgia", "size": 20, "color": (255, 255, 0)} - original_text = TextBoxData( - text_content="Round Trip Test", - font_settings=font_settings, - alignment="center", - x=85.0, - y=95.0, - width=320.0, - height=120.0, - rotation=25.0, - z_index=9 - ) - - data = original_text.serialize() - restored_text = TextBoxData() - restored_text.deserialize(data) - - assert restored_text.text_content == original_text.text_content - assert restored_text.alignment == original_text.alignment - assert restored_text.position == original_text.position - print(" TextBoxData round-trip successful!") - - -def example_4_export_to_pdf(): - """ - Example 4: Export a project to PDF - - Based on pdf_exporter.py usage - """ - print("\n" + "="*60) - print("Example 4: Export to PDF") - print("="*60) - - # Create a simple project - output_dir = Path(__file__).parent / "output" - output_dir.mkdir(exist_ok=True) - - temp_dir = tempfile.mkdtemp(prefix="photo_album_pdf_") - project_folder = os.path.join(temp_dir, "pdf_export") - - project = Project(name="PDF Export Example", folder_path=project_folder) - project.page_size_mm = (140, 140) # Square format - project.working_dpi = 300 - project.export_dpi = 300 - - print(f"\nCreating project with {project.page_size_mm[0]}x{project.page_size_mm[1]}mm pages") - - # Create multiple pages with different colored images - colors = ['red', 'green', 'blue', 'yellow'] - for i, color in enumerate(colors, 1): - # Create sample image - image_path = output_dir / f"pdf_sample_{color}.jpg" - create_sample_image(str(image_path), color=color, size=(600, 600)) - - # Import and add to page - imported_path = project.asset_manager.import_asset(str(image_path)) - - layout = PageLayout(width=140, height=140) - image = ImageData( - image_path=imported_path, - x=10.0, - y=10.0, - width=120.0, - height=120.0 - ) - layout.add_element(image) - - page = Page(layout=layout, page_number=i) - project.add_page(page) - - print(f"Created {len(project.pages)} pages") - - # Export to PDF - pdf_path = output_dir / "example_album.pdf" - print(f"\nExporting to PDF: {pdf_path}") - - exporter = PDFExporter(project, export_dpi=300) - - def progress_callback(current, total): - print(f" Exporting page {current}/{total}...") - - success, errors = exporter.export( - output_path=str(pdf_path), - progress_callback=progress_callback - ) - - if success: - print(f"\nPDF exported successfully!") - print(f"File size: {os.path.getsize(pdf_path) / 1024:.1f} KB") - else: - print(f"\nErrors during export:") - for error in errors: - print(f" - {error}") - - -def main(): - """Run all examples""" - print("\n" + "="*60) - print("pyPhotoAlbum - Basic Usage Examples") - print("="*60) - print("\nThese examples demonstrate core functionality using") - print("code patterns from the pyPhotoAlbum unit tests.\n") - - try: - # Example 1: Create a basic project - project_path = example_1_create_basic_project() - - # Example 2: Load and modify - if project_path and os.path.exists(project_path): - example_2_load_and_modify_project(project_path) - - # Example 3: Serialization - example_3_serialization_roundtrip() - - # Example 4: PDF export - example_4_export_to_pdf() - - print("\n" + "="*60) - print("All examples completed successfully!") - print("="*60) - print(f"\nOutput files are in: {Path(__file__).parent / 'output'}") - - except Exception as e: - print(f"\nError running examples: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/examples/generate_screenshots.py b/examples/generate_screenshots.py deleted file mode 100644 index 2d6fbc4..0000000 --- a/examples/generate_screenshots.py +++ /dev/null @@ -1,427 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Documentation Screenshots - -This script creates visual examples for documentation purposes: -- Sample layouts -- Template demonstrations -- Feature showcases -- UI mockups - -Note: This creates programmatic representations rather than actual screenshots. -For real screenshots, run the application and use a screen capture tool. -""" - -import os -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyPhotoAlbum.project import Project, Page -from pyPhotoAlbum.page_layout import PageLayout -from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData -from pyPhotoAlbum.project_serializer import save_to_zip -import tempfile - - -def create_visual_mockup(path: str, title: str, description: str, elements: list): - """ - Create a visual mockup image showing a layout - - Args: - path: Output path for the image - title: Title for the mockup - description: Description text - elements: List of (type, x, y, w, h, label) tuples - """ - try: - from PIL import Image, ImageDraw, ImageFont - - # Create canvas (A4 aspect ratio) - width, height = 800, 1132 - img = Image.new('RGB', (width, height), color='white') - draw = ImageDraw.Draw(img) - - # Try to load a nice font - try: - title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32) - desc_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18) - label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) - except: - title_font = ImageFont.load_default() - desc_font = ImageFont.load_default() - label_font = ImageFont.load_default() - - # Draw title - draw.text((20, 20), title, fill='black', font=title_font) - - # Draw description - draw.text((20, 65), description, fill='gray', font=desc_font) - - # Draw page boundary - page_margin = 50 - page_top = 120 - page_bottom = height - 50 - page_width = width - 2 * page_margin - page_height = page_bottom - page_top - - # Light gray page background - draw.rectangle( - [(page_margin, page_top), (width - page_margin, page_bottom)], - fill='#f5f5f5', - outline='#cccccc', - width=2 - ) - - # Draw elements - colors = { - 'image': '#e3f2fd', # Light blue - 'text': '#fff9c4', # Light yellow - 'placeholder': '#e8f5e9' # Light green - } - - for elem_type, x, y, w, h, label in elements: - # Convert from mm to pixels (rough approximation) - px = page_margin + (x / 210) * page_width - py = page_top + (y / 297) * page_height - pw = (w / 210) * page_width - ph = (h / 297) * page_height - - # Draw element - color = colors.get(elem_type, '#eeeeee') - draw.rectangle( - [(px, py), (px + pw, py + ph)], - fill=color, - outline='#666666', - width=2 - ) - - # Draw label - if label: - label_bbox = draw.textbbox((0, 0), label, font=label_font) - label_w = label_bbox[2] - label_bbox[0] - label_h = label_bbox[3] - label_bbox[1] - label_x = px + (pw - label_w) / 2 - label_y = py + (ph - label_h) / 2 - draw.text((label_x, label_y), label, fill='#333333', font=label_font) - - # Save - img.save(path) - print(f" Created: {path}") - return True - - except Exception as e: - print(f" Error creating mockup: {e}") - return False - - -def generate_layout_examples(): - """Generate example layout screenshots""" - print("\n" + "="*60) - print("Generating Layout Examples") - print("="*60) - - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - - # Example 1: Single large image - print("\n1. Single Large Image Layout") - create_visual_mockup( - str(output_dir / "layout_single_large.png"), - "Single Large Image Layout", - "One large image with caption", - [ - ('image', 10, 10, 190, 250, 'Large Image'), - ('text', 10, 270, 190, 20, 'Caption Text') - ] - ) - - # Example 2: Grid 2x2 - print("\n2. Grid 2x2 Layout") - create_visual_mockup( - str(output_dir / "layout_grid_2x2.png"), - "Grid 2x2 Layout", - "Four images in a 2x2 grid", - [ - ('image', 10, 10, 95, 95, 'Image 1'), - ('image', 105, 10, 95, 95, 'Image 2'), - ('image', 10, 110, 95, 95, 'Image 3'), - ('image', 105, 110, 95, 95, 'Image 4') - ] - ) - - # Example 3: Mixed layout - print("\n3. Mixed Layout") - create_visual_mockup( - str(output_dir / "layout_mixed.png"), - "Mixed Layout", - "Combination of images and text boxes", - [ - ('image', 10, 10, 190, 120, 'Header Image'), - ('text', 10, 140, 190, 25, 'Title'), - ('image', 10, 175, 90, 90, 'Image 1'), - ('image', 110, 175, 90, 90, 'Image 2'), - ('text', 10, 270, 190, 15, 'Footer') - ] - ) - - # Example 4: Template with placeholders - print("\n4. Template with Placeholders") - create_visual_mockup( - str(output_dir / "layout_template.png"), - "Template Layout", - "Page template with placeholder blocks", - [ - ('placeholder', 10, 10, 190, 140, 'Drop Image Here'), - ('placeholder', 10, 160, 90, 90, 'Image'), - ('placeholder', 110, 160, 90, 90, 'Image'), - ('text', 10, 260, 190, 25, 'Text Box') - ] - ) - - -def generate_feature_examples(): - """Generate feature demonstration screenshots""" - print("\n" + "="*60) - print("Generating Feature Examples") - print("="*60) - - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - - # Alignment demonstration - print("\n1. Alignment Features") - create_visual_mockup( - str(output_dir / "feature_alignment.png"), - "Alignment Tools", - "Align left, center, right, and distribute", - [ - ('image', 10, 10, 50, 50, 'Img 1'), - ('image', 70, 10, 50, 50, 'Img 2'), - ('image', 130, 10, 50, 50, 'Img 3'), - ('text', 10, 80, 170, 15, 'Aligned and distributed horizontally') - ] - ) - - # Sizing demonstration - print("\n2. Sizing Features") - create_visual_mockup( - str(output_dir / "feature_sizing.png"), - "Sizing Tools", - "Make same size, width, or height", - [ - ('image', 10, 10, 80, 80, 'Same Size'), - ('image', 100, 10, 80, 80, 'Same Size'), - ('image', 10, 100, 80, 60, 'Same Width'), - ('image', 100, 100, 80, 80, 'Same Width') - ] - ) - - # Z-order demonstration - print("\n3. Z-Order (Layering)") - create_visual_mockup( - str(output_dir / "feature_zorder.png"), - "Z-Order / Layering", - "Elements can be ordered in layers", - [ - ('image', 30, 30, 100, 100, 'Background'), - ('image', 60, 60, 100, 100, 'Middle'), - ('image', 90, 90, 100, 100, 'Foreground') - ] - ) - - -def generate_workflow_example(): - """Generate a complete workflow example""" - print("\n" + "="*60) - print("Generating Workflow Example") - print("="*60) - - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - - # Step 1: Empty page - print("\n1. Start with empty page") - create_visual_mockup( - str(output_dir / "workflow_01_empty.png"), - "Step 1: Empty Page", - "Start with a blank page", - [] - ) - - # Step 2: Apply template - print("\n2. Apply template") - create_visual_mockup( - str(output_dir / "workflow_02_template.png"), - "Step 2: Apply Template", - "Add placeholder blocks using a template", - [ - ('placeholder', 10, 10, 90, 90, 'Drop Here'), - ('placeholder', 110, 10, 90, 90, 'Drop Here'), - ('placeholder', 10, 110, 90, 90, 'Drop Here'), - ('placeholder', 110, 110, 90, 90, 'Drop Here') - ] - ) - - # Step 3: Add images - print("\n3. Add images") - create_visual_mockup( - str(output_dir / "workflow_03_images.png"), - "Step 3: Add Images", - "Drag and drop images onto placeholders", - [ - ('image', 10, 10, 90, 90, 'Photo 1'), - ('image', 110, 10, 90, 90, 'Photo 2'), - ('image', 10, 110, 90, 90, 'Photo 3'), - ('placeholder', 110, 110, 90, 90, 'Drop Here') - ] - ) - - # Step 4: Final layout - print("\n4. Final layout") - create_visual_mockup( - str(output_dir / "workflow_04_final.png"), - "Step 4: Final Layout", - "Complete page with all images and text", - [ - ('image', 10, 10, 90, 90, 'Photo 1'), - ('image', 110, 10, 90, 90, 'Photo 2'), - ('image', 10, 110, 90, 90, 'Photo 3'), - ('image', 110, 110, 90, 90, 'Photo 4'), - ('text', 10, 210, 190, 20, 'My Photo Album - Summer 2024') - ] - ) - - -def generate_template_examples(): - """Generate template system examples""" - print("\n" + "="*60) - print("Generating Template Examples") - print("="*60) - - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - - # Grid template - print("\n1. Grid_2x2 Template") - create_visual_mockup( - str(output_dir / "template_grid_2x2.png"), - "Grid_2x2 Template", - "Built-in 2x2 grid template", - [ - ('placeholder', 10, 10, 95, 133, ''), - ('placeholder', 105, 10, 95, 133, ''), - ('placeholder', 10, 143, 95, 134, ''), - ('placeholder', 105, 143, 95, 134, '') - ] - ) - - # Single large template - print("\n2. Single_Large Template") - create_visual_mockup( - str(output_dir / "template_single_large.png"), - "Single_Large Template", - "Built-in single large image template", - [ - ('placeholder', 10, 10, 190, 240, 'Large Image'), - ('text', 10, 260, 190, 25, 'Title') - ] - ) - - -def generate_project_structure_diagram(): - """Generate project structure visualization""" - print("\n" + "="*60) - print("Generating Project Structure Diagram") - print("="*60) - - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - - try: - from PIL import Image, ImageDraw, ImageFont - - width, height = 800, 600 - img = Image.new('RGB', (width, height), color='white') - draw = ImageDraw.Draw(img) - - try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) - title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) - except: - font = ImageFont.load_default() - title_font = ImageFont.load_default() - - # Title - draw.text((20, 20), "pyPhotoAlbum Project Structure", fill='black', font=title_font) - - # Draw structure - structure = [ - (50, "Project", 60), - (100, "├─ Page 1", 100), - (150, "│ ├─ PageLayout", 140), - (200, "│ │ ├─ ImageData", 180), - (250, "│ │ ├─ TextBoxData", 220), - (300, "│ │ └─ PlaceholderData", 260), - (100, "├─ Page 2", 320), - (100, "└─ AssetManager", 360), - (150, " └─ assets/", 400), - ] - - for x, text, y in structure: - draw.text((x, y), text, fill='#333333', font=font) - - # Draw connecting lines (simplified) - draw.line([(60, 90), (60, 380)], fill='#666666', width=2) - draw.line([(110, 130), (110, 280)], fill='#666666', width=2) - - path = output_dir / "project_structure.png" - img.save(str(path)) - print(f" Created: {path}") - - except Exception as e: - print(f" Error creating diagram: {e}") - - -def main(): - """Generate all documentation screenshots""" - print("\n" + "="*60) - print("pyPhotoAlbum - Generate Documentation Screenshots") - print("="*60) - print("\nThis script creates visual examples for documentation.") - print("For actual application screenshots, run the app and") - print("use a screen capture tool.\n") - - try: - # Create output directory - output_dir = Path(__file__).parent / "screenshots" - output_dir.mkdir(exist_ok=True) - print(f"Output directory: {output_dir}") - - # Generate examples - generate_layout_examples() - generate_feature_examples() - generate_workflow_example() - generate_template_examples() - generate_project_structure_diagram() - - print("\n" + "="*60) - print("Screenshot generation complete!") - print("="*60) - print(f"\nGenerated files are in: {output_dir}") - print("\nThese mockups can be used in documentation to illustrate:") - print(" - Page layouts") - print(" - Template system") - print(" - Feature demonstrations") - print(" - Workflow examples") - - except Exception as e: - print(f"\nError generating screenshots: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/examples/template_example.py b/examples/template_example.py deleted file mode 100644 index f82f33e..0000000 --- a/examples/template_example.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -""" -Template System Example for pyPhotoAlbum - -This example demonstrates: -- Creating templates from existing pages -- Using built-in templates -- Applying templates to new pages -- Template scaling modes -- Saving and loading custom templates - -Based on template_manager.py and TEMPLATES_README.md -""" - -import os -import sys -import tempfile -from pathlib import Path - -# Add parent directory to path to import pyPhotoAlbum -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyPhotoAlbum.project import Project, Page -from pyPhotoAlbum.page_layout import PageLayout -from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData -from pyPhotoAlbum.template_manager import TemplateManager, Template -from pyPhotoAlbum.project_serializer import save_to_zip - - -def create_sample_image(path: str, color: str = 'blue', size: tuple = (400, 300)): - """Create a sample image for testing""" - try: - from PIL import Image, ImageDraw, ImageFont - - img = Image.new('RGB', size, color=color) - draw = ImageDraw.Draw(img) - - # Draw a border - border_width = 10 - draw.rectangle( - [(border_width, border_width), (size[0]-border_width, size[1]-border_width)], - outline='white', - width=5 - ) - - # Add text - text = f"{color.upper()}" - try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) - except: - font = ImageFont.load_default() - - bbox = draw.textbbox((0, 0), text, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - x = (size[0] - text_width) // 2 - y = (size[1] - text_height) // 2 - - draw.text((x, y), text, fill='white', font=font) - img.save(path) - print(f" Created: {path}") - return True - except Exception as e: - print(f" Could not create image: {e}") - return False - - -def example_1_list_templates(): - """ - Example 1: List available templates - - From template_manager.py - """ - print("\n" + "="*60) - print("Example 1: Listing Available Templates") - print("="*60) - - manager = TemplateManager() - - print("\nAvailable templates:") - templates = manager.list_templates() - for i, template_name in enumerate(templates, 1): - print(f" {i}. {template_name}") - - # Load and display info about each template - print("\nTemplate Details:") - for template_name in templates[:2]: # Show first 2 templates - try: - template = manager.load_template(template_name) - print(f"\n {template.name}:") - print(f" Description: {template.description}") - print(f" Page Size: {template.page_size_mm[0]}x{template.page_size_mm[1]} mm") - print(f" Elements: {len(template.elements)}") - except Exception as e: - print(f" Error loading: {e}") - - -def example_2_create_page_from_template(): - """ - Example 2: Create a new page from a built-in template - - From template_manager.py - create_page_from_template method - """ - print("\n" + "="*60) - print("Example 2: Creating Page from Template") - print("="*60) - - manager = TemplateManager() - output_dir = Path(__file__).parent / "output" - output_dir.mkdir(exist_ok=True) - - # Create a project - temp_dir = tempfile.mkdtemp(prefix="template_example_") - project_folder = os.path.join(temp_dir, "template_project") - - project = Project(name="Template Example", folder_path=project_folder) - project.page_size_mm = (210, 297) # A4 - project.working_dpi = 300 - - print(f"\nCreated project: {project.name}") - print(f"Page size: {project.page_size_mm[0]}x{project.page_size_mm[1]} mm") - - # Create a page from Grid_2x2 template - print("\nCreating page from 'Grid_2x2' template...") - page = manager.create_page_from_template( - template_name="Grid_2x2", - target_page_size=project.page_size_mm, - page_number=1 - ) - - print(f" Created page with {len(page.layout.elements)} placeholder elements") - - # Show details of placeholders - for i, element in enumerate(page.layout.elements, 1): - if isinstance(element, PlaceholderData): - print(f" Placeholder {i}: position={element.position}, size={element.size}") - - project.add_page(page) - - # Now replace placeholders with actual images - print("\nReplacing placeholders with images...") - colors = ['red', 'green', 'blue', 'yellow'] - - for i, element in enumerate(page.layout.elements): - if isinstance(element, PlaceholderData) and i < len(colors): - # Create sample image - image_path = output_dir / f"grid_image_{colors[i]}.jpg" - create_sample_image(str(image_path), color=colors[i], size=(600, 600)) - - # Import to project - imported_path = project.asset_manager.import_asset(str(image_path)) - - # Replace placeholder with image - image = ImageData( - image_path=imported_path, - x=element.position[0], - y=element.position[1], - width=element.size[0], - height=element.size[1], - z_index=element.z_index - ) - - # Remove placeholder and add image - page.layout.remove_element(element) - page.layout.add_element(image) - print(f" Replaced placeholder {i+1} with {colors[i]} image") - - # Save project - project_path = output_dir / "template_project.ppz" - print(f"\nSaving project to: {project_path}") - success, error = save_to_zip(project, str(project_path)) - - if success: - print(" Project saved successfully!") - else: - print(f" Error: {error}") - - -def example_3_create_custom_template(): - """ - Example 3: Create a custom template from a page - - From template_manager.py - create_template_from_page method - """ - print("\n" + "="*60) - print("Example 3: Creating Custom Template") - print("="*60) - - manager = TemplateManager() - output_dir = Path(__file__).parent / "output" - output_dir.mkdir(exist_ok=True) - - # Create a page with a custom layout - print("\nDesigning custom page layout...") - layout = PageLayout(width=210, height=297) - - # Large image at top - top_image = ImageData( - image_path="dummy.jpg", # Will be converted to placeholder - x=10.0, - y=10.0, - width=190.0, - height=140.0, - z_index=0 - ) - layout.add_element(top_image) - print(" Added large top image") - - # Two smaller images at bottom - bottom_left = ImageData( - image_path="dummy.jpg", - x=10.0, - y=160.0, - width=90.0, - height=90.0, - z_index=0 - ) - layout.add_element(bottom_left) - - bottom_right = ImageData( - image_path="dummy.jpg", - x=110.0, - y=160.0, - width=90.0, - height=90.0, - z_index=0 - ) - layout.add_element(bottom_right) - print(" Added two smaller bottom images") - - # Title text box - title = TextBoxData( - text_content="Title", - font_settings={"family": "Arial", "size": 20, "color": (0, 0, 0)}, - alignment="center", - x=10.0, - y=260.0, - width=190.0, - height=30.0, - z_index=1 - ) - layout.add_element(title) - print(" Added title text box") - - # Create page - page = Page(layout=layout, page_number=1) - - # Create template from page - print("\nCreating template from page...") - template = manager.create_template_from_page( - page=page, - name="Custom_Large_Plus_Two", - description="One large image at top, two smaller at bottom, with title" - ) - - print(f" Template created: {template.name}") - print(f" Description: {template.description}") - print(f" Elements: {len(template.elements)}") - - # Save template - print("\nSaving custom template...") - try: - manager.save_template(template) - print(f" Template saved successfully!") - print(f" Location: {manager._get_templates_directory()}") - except Exception as e: - print(f" Error saving template: {e}") - - # Verify it's in the list - templates = manager.list_templates() - if template.name in templates: - print(f" Verified: Template appears in list") - - -def example_4_template_scaling_modes(): - """ - Example 4: Demonstrate different template scaling modes - - From template_manager.py - scale_template_elements method - """ - print("\n" + "="*60) - print("Example 4: Template Scaling Modes") - print("="*60) - - manager = TemplateManager() - - # Create a simple template - print("\nCreating test template (100x100mm square)...") - template = Template(name="Test Square", page_size_mm=(100, 100)) - - # Add a centered square element - element = PlaceholderData( - x=25.0, y=25.0, - width=50.0, height=50.0 - ) - template.add_element(element) - - print(f" Original element: position={element.position}, size={element.size}") - - # Test different target sizes - target_sizes = [ - (200, 200, "Same aspect ratio (2x)"), - (200, 100, "Wider (2x wide, 1x tall)"), - (100, 200, "Taller (1x wide, 2x tall)") - ] - - scaling_modes = ["proportional", "stretch", "center"] - - for target_w, target_h, desc in target_sizes: - print(f"\nTarget size: {target_w}x{target_h}mm ({desc})") - - for mode in scaling_modes: - scaled = manager.scale_template_elements( - template.elements.copy(), - source_size=template.page_size_mm, - target_size=(target_w, target_h), - scaling=mode - ) - - if scaled: - scaled_element = scaled[0] - print(f" {mode:12s}: position={scaled_element.position}, size={scaled_element.size}") - - -def example_5_apply_template_modes(): - """ - Example 5: Apply template to existing page with different modes - - From template_manager.py - apply_template_to_page method - """ - print("\n" + "="*60) - print("Example 5: Applying Template to Existing Page") - print("="*60) - - manager = TemplateManager() - output_dir = Path(__file__).parent / "output" - output_dir.mkdir(exist_ok=True) - - # Create a page with some existing content - print("\nCreating page with existing images...") - temp_dir = tempfile.mkdtemp(prefix="template_apply_") - project_folder = os.path.join(temp_dir, "apply_project") - - project = Project(name="Apply Template Test", folder_path=project_folder) - project.page_size_mm = (210, 297) - - layout = PageLayout(width=210, height=297) - - # Add some images - colors = ['red', 'green', 'blue'] - for i, color in enumerate(colors): - image_path = output_dir / f"apply_{color}.jpg" - create_sample_image(str(image_path), color=color, size=(600, 400)) - imported_path = project.asset_manager.import_asset(str(image_path)) - - image = ImageData( - image_path=imported_path, - x=10.0 + i*20, - y=10.0 + i*30, - width=100.0, - height=100.0 - ) - layout.add_element(image) - - page = Page(layout=layout, page_number=1) - project.add_page(page) - - print(f" Created page with {len(page.layout.elements)} images") - - # Apply Grid_2x2 template with "reflow" mode - print("\nApplying Grid_2x2 template with 'reflow' mode...") - print(" This will reposition existing images to fit template slots") - - try: - template = manager.load_template("Grid_2x2") - manager.apply_template_to_page( - template=template, - target_page=page, - mode="reflow", # Keep existing images, reposition them - scaling="proportional" - ) - - print(f" Template applied! Page now has {len(page.layout.elements)} elements") - - # Show new positions - for i, element in enumerate(page.layout.elements, 1): - print(f" Element {i}: position={element.position}, size={element.size}") - - except Exception as e: - print(f" Error applying template: {e}") - - # Save result - project_path = output_dir / "template_applied.ppz" - print(f"\nSaving result to: {project_path}") - success, error = save_to_zip(project, str(project_path)) - - if success: - print(" Saved successfully!") - - -def main(): - """Run all template examples""" - print("\n" + "="*60) - print("pyPhotoAlbum - Template System Examples") - print("="*60) - print("\nDemonstrating the template system using code from") - print("template_manager.py and TEMPLATES_README.md\n") - - try: - # Example 1: List templates - example_1_list_templates() - - # Example 2: Create page from template - example_2_create_page_from_template() - - # Example 3: Create custom template - example_3_create_custom_template() - - # Example 4: Scaling modes - example_4_template_scaling_modes() - - # Example 5: Apply template - example_5_apply_template_modes() - - print("\n" + "="*60) - print("All template examples completed!") - print("="*60) - print(f"\nOutput files are in: {Path(__file__).parent / 'output'}") - - except Exception as e: - print(f"\nError running examples: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index c982542..a047f2f 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -480,6 +480,117 @@ class ResizeElementsCommand(Command): return ResizeElementsCommand(changes) +class ChangeZOrderCommand(Command): + """Command for changing element z-order (list position)""" + + def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int): + self.page_layout = page_layout + self.element = element + self.old_index = old_index + self.new_index = new_index + + def execute(self): + """Move element to new position in list""" + elements = self.page_layout.elements + if self.element in elements: + elements.remove(self.element) + elements.insert(self.new_index, self.element) + + def undo(self): + """Move element back to old position in list""" + elements = self.page_layout.elements + if self.element in elements: + elements.remove(self.element) + elements.insert(self.old_index, self.element) + + def redo(self): + """Move element to new position again""" + self.execute() + + def serialize(self) -> Dict[str, Any]: + """Serialize to dictionary""" + return { + "type": "change_zorder", + "element": self.element.serialize(), + "old_index": self.old_index, + "new_index": self.new_index + } + + @staticmethod + def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand': + """Deserialize from dictionary""" + elem_data = data["element"] + elem_type = elem_data.get("type") + + if elem_type == "image": + element = ImageData() + elif elem_type == "placeholder": + element = PlaceholderData() + elif elem_type == "textbox": + element = TextBoxData() + else: + raise ValueError(f"Unknown element type: {elem_type}") + + element.deserialize(elem_data) + + return ChangeZOrderCommand( + None, # page_layout will be set by CommandHistory + element, + data["old_index"], + data["new_index"] + ) + + +class StateChangeCommand(Command): + """ + Generic command for operations that change state. + + This command captures before/after snapshots of state and can restore them. + Used by the @undoable_operation decorator. + """ + + def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None): + """ + Args: + description: Human-readable description of the operation + restore_func: Function to restore state: restore_func(state) + before_state: State before the operation + after_state: State after the operation (captured during execute) + """ + self.description = description + self.restore_func = restore_func + self.before_state = before_state + self.after_state = after_state + + def execute(self): + """State is already applied, just store after_state if not set""" + # After state is captured by decorator after operation runs + pass + + def undo(self): + """Restore to before state""" + self.restore_func(self.before_state) + + def redo(self): + """Restore to after state""" + self.restore_func(self.after_state) + + def serialize(self) -> Dict[str, Any]: + """Serialize to dictionary""" + # For now, state change commands are not serialized + # This could be enhanced later if needed + return { + "type": "state_change", + "description": self.description + } + + @staticmethod + def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand': + """Deserialize from dictionary""" + # Not implemented - would need to serialize state + raise NotImplementedError("StateChangeCommand deserialization not yet supported") + + class CommandHistory: """Manages undo/redo command history""" @@ -604,6 +715,8 @@ class CommandHistory: return AlignElementsCommand.deserialize(data, project) elif cmd_type == "resize_elements": return ResizeElementsCommand.deserialize(data, project) + elif cmd_type == "change_zorder": + return ChangeZOrderCommand.deserialize(data, project) else: print(f"Warning: Unknown command type: {cmd_type}") return None diff --git a/pyPhotoAlbum/decorators.py b/pyPhotoAlbum/decorators.py index 873ce5b..146883c 100644 --- a/pyPhotoAlbum/decorators.py +++ b/pyPhotoAlbum/decorators.py @@ -72,8 +72,12 @@ class RibbonAction: Returns: The decorated function with metadata attached """ - # Store metadata on function - func._ribbon_action = { + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # Store metadata on wrapper function + wrapper._ribbon_action = { 'label': self.label, 'tooltip': self.tooltip, 'tab': self.tab, @@ -86,10 +90,6 @@ class RibbonAction: 'min_selection': self.min_selection } - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper @@ -174,15 +174,15 @@ class NumericalInput: Returns: The decorated function with metadata attached """ - # Store metadata on function - func._numerical_input = { - 'fields': self.fields - } - @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) + # Store metadata on wrapper function + wrapper._numerical_input = { + 'fields': self.fields + } + return wrapper @@ -200,3 +200,129 @@ def numerical_input(fields: list) -> Callable: NumericalInput decorator instance """ return NumericalInput(fields=fields) + + +class UndoableOperation: + """ + Decorator to automatically create undo/redo commands for operations. + + This decorator captures state before and after an operation, then creates + a StateChangeCommand for undo/redo functionality. + + Example: + @undoable_operation(capture='page_elements') + def apply_template(self): + # Just implement the operation + self.template_manager.apply_template(...) + # Decorator handles undo/redo automatically + """ + + def __init__(self, capture: str = 'page_elements', description: str = None): + """ + Initialize the undoable operation decorator. + + Args: + capture: What to capture for undo/redo: + - 'page_elements': Capture elements of current page + - 'custom': Operation provides its own capture logic + description: Human-readable description (defaults to function name) + """ + self.capture = capture + self.description = description + + def __call__(self, func: Callable) -> Callable: + """ + Decorate the function with automatic undo/redo. + + Args: + func: The function to decorate + + Returns: + The decorated function + """ + @wraps(func) + def wrapper(self_instance, *args, **kwargs): + # Get description + description = self.description or func.__name__.replace('_', ' ').title() + + # Capture before state + before_state = self._capture_state(self_instance, self.capture) + + # Execute the operation + result = func(self_instance, *args, **kwargs) + + # Capture after state + after_state = self._capture_state(self_instance, self.capture) + + # Create restore function + def restore_state(state): + self._restore_state(self_instance, self.capture, state) + # Update view after restoring + if hasattr(self_instance, 'update_view'): + self_instance.update_view() + + # Create and execute command + from pyPhotoAlbum.commands import StateChangeCommand + cmd = StateChangeCommand(description, restore_state, before_state, after_state) + + if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'): + self_instance.project.history.execute(cmd) + print(f"Undoable operation '{description}' executed") + + return result + + return wrapper + + def _capture_state(self, instance, capture_type: str): + """Capture current state based on capture type""" + if capture_type == 'page_elements': + # Capture elements from current page + current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None + if current_page: + # Deep copy elements + import copy + return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements] + return [] + + return None + + def _restore_state(self, instance, capture_type: str, state): + """Restore state based on capture type""" + if capture_type == 'page_elements': + # Restore elements to current page + current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None + if current_page and state is not None: + # Clear existing elements + current_page.layout.elements.clear() + + # Restore elements from serialized state + from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData + for elem_data in state: + elem_type = elem_data.get('type') + if elem_type == 'image': + elem = ImageData() + elif elem_type == 'placeholder': + elem = PlaceholderData() + elif elem_type == 'textbox': + elem = TextBoxData() + else: + continue + + elem.deserialize(elem_data) + current_page.layout.add_element(elem) + + +def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable: + """ + Convenience function for the UndoableOperation decorator. + + This provides a lowercase function-style interface to the decorator. + + Args: + capture: What to capture for undo/redo + description: Human-readable description of the operation + + Returns: + UndoableOperation decorator instance + """ + return UndoableOperation(capture=capture, description=description) diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 0cbbb2d..2e03994 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -7,9 +7,10 @@ from PyQt6.QtCore import Qt from OpenGL.GL import * from pyPhotoAlbum.models import ImageData, PlaceholderData from pyPhotoAlbum.commands import AddElementCommand +from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin -class GLWidget(QOpenGLWidget): +class GLWidget(UndoableInteractionMixin, QOpenGLWidget): """OpenGL widget for rendering pages and handling user interaction""" def __init__(self, parent=None): @@ -427,7 +428,8 @@ class GLWidget(QOpenGLWidget): if len(self.selected_elements) == 1 and self.selected_element: if self.rotation_mode: - # In rotation mode, start rotation + # In rotation mode, start rotation tracking + self._begin_rotate(self.selected_element) self.drag_start_pos = (x, y) self.rotation_start_angle = self.selected_element.rotation self.is_dragging = True @@ -436,6 +438,7 @@ class GLWidget(QOpenGLWidget): # In normal mode, check for resize handles handle = self._get_resize_handle_at(x, y) if handle: + self._begin_resize(self.selected_element) self.resize_handle = handle self.drag_start_pos = (x, y) self.resize_start_pos = self.selected_element.position @@ -455,6 +458,7 @@ class GLWidget(QOpenGLWidget): self.drag_start_pos = (x, y) self.drag_start_element_pos = element.position if not self.rotation_mode: + self._begin_move(element) self.is_dragging = True else: if not ctrl_pressed: @@ -579,28 +583,14 @@ class GLWidget(QOpenGLWidget): def mouseReleaseEvent(self, event): """Handle mouse release events""" if event.button() == Qt.MouseButton.LeftButton: - # If we were rotating, create a RotateElementCommand for undo/redo - if self.rotation_mode and self.rotation_start_angle is not None and self.selected_element: - from pyPhotoAlbum.commands import RotateElementCommand - - new_angle = self.selected_element.rotation - if abs(new_angle - self.rotation_start_angle) > 0.1: # Only create command if angle changed - main_window = self.window() - if hasattr(main_window, 'project'): - cmd = RotateElementCommand( - self.selected_element, - self.rotation_start_angle, - new_angle - ) - main_window.project.history.execute(cmd) - print(f"Rotation command created: {self.rotation_start_angle:.1f}° → {new_angle:.1f}°") - - self.rotation_start_angle = None + # End any active interaction - mixin will create appropriate undo/redo command + self._end_interaction() self.is_dragging = False self.drag_start_pos = None self.drag_start_element_pos = None self.resize_handle = None + self.rotation_start_angle = None self.snap_state = { 'is_snapped': False, 'last_position': None, @@ -686,9 +676,8 @@ class GLWidget(QOpenGLWidget): # Convert screen coordinates to page-local coordinates page_x, page_y = renderer.screen_to_page(x, y) - # Check elements in this page (highest z-index first) - elements = sorted(page.layout.elements, key=lambda e: e.z_index, reverse=True) - for element in elements: + # Check elements in this page (highest in list = on top, so check in reverse) + for element in reversed(page.layout.elements): ex, ey = element.position ew, eh = element.size diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index a8ce8f0..00e51fe 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -32,6 +32,7 @@ from pyPhotoAlbum.mixins.operations import ( AlignmentOperationsMixin, DistributionOperationsMixin, SizeOperationsMixin, + ZOrderOperationsMixin, ) @@ -47,6 +48,7 @@ class MainWindow( AlignmentOperationsMixin, DistributionOperationsMixin, SizeOperationsMixin, + ZOrderOperationsMixin, ): """ Main application window using mixin architecture. diff --git a/pyPhotoAlbum/mixins/interaction_undo.py b/pyPhotoAlbum/mixins/interaction_undo.py new file mode 100644 index 0000000..aedbf3a --- /dev/null +++ b/pyPhotoAlbum/mixins/interaction_undo.py @@ -0,0 +1,162 @@ +""" +Mixin for automatic undo/redo handling in interactive mouse operations +""" + +from typing import Optional +from pyPhotoAlbum.models import BaseLayoutElement + + +class UndoableInteractionMixin: + """ + Mixin providing automatic undo/redo for interactive mouse operations. + + This mixin tracks the state of elements before interactive operations + (move, resize, rotate) and automatically creates appropriate Command + objects when the interaction completes. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Interaction tracking state + self._interaction_element: Optional[BaseLayoutElement] = None + self._interaction_type: Optional[str] = None + self._interaction_start_pos: Optional[tuple] = None + self._interaction_start_size: Optional[tuple] = None + self._interaction_start_rotation: Optional[float] = None + + def _begin_move(self, element: BaseLayoutElement): + """ + Begin tracking a move operation. + + Args: + element: The element being moved + """ + self._interaction_element = element + self._interaction_type = 'move' + self._interaction_start_pos = element.position + self._interaction_start_size = None + self._interaction_start_rotation = None + + def _begin_resize(self, element: BaseLayoutElement): + """ + Begin tracking a resize operation. + + Args: + element: The element being resized + """ + self._interaction_element = element + self._interaction_type = 'resize' + self._interaction_start_pos = element.position + self._interaction_start_size = element.size + self._interaction_start_rotation = None + + def _begin_rotate(self, element: BaseLayoutElement): + """ + Begin tracking a rotate operation. + + Args: + element: The element being rotated + """ + self._interaction_element = element + self._interaction_type = 'rotate' + self._interaction_start_pos = None + self._interaction_start_size = None + self._interaction_start_rotation = element.rotation + + def _end_interaction(self): + """ + End the current interaction and create appropriate undo/redo command. + + This method checks what changed during the interaction and creates + the appropriate Command object (MoveElementCommand, ResizeElementCommand, + or RotateElementCommand). + """ + if not self._interaction_element or not self._interaction_type: + self._clear_interaction_state() + return + + element = self._interaction_element + + # Get main window to access project history + main_window = self.window() + if not hasattr(main_window, 'project'): + self._clear_interaction_state() + return + + # Create appropriate command based on interaction type + command = None + + if self._interaction_type == 'move': + # Check if position actually changed + new_pos = element.position + if self._interaction_start_pos and new_pos != self._interaction_start_pos: + # Check for significant change (> 0.1 units) + dx = abs(new_pos[0] - self._interaction_start_pos[0]) + dy = abs(new_pos[1] - self._interaction_start_pos[1]) + if dx > 0.1 or dy > 0.1: + from pyPhotoAlbum.commands import MoveElementCommand + command = MoveElementCommand( + element, + self._interaction_start_pos, + new_pos + ) + print(f"Move command created: {self._interaction_start_pos} → {new_pos}") + + elif self._interaction_type == 'resize': + # Check if position or size actually changed + new_pos = element.position + new_size = element.size + if self._interaction_start_pos and self._interaction_start_size: + pos_changed = new_pos != self._interaction_start_pos + size_changed = new_size != self._interaction_start_size + + if pos_changed or size_changed: + # Check for significant change + dx = abs(new_pos[0] - self._interaction_start_pos[0]) + dy = abs(new_pos[1] - self._interaction_start_pos[1]) + dw = abs(new_size[0] - self._interaction_start_size[0]) + dh = abs(new_size[1] - self._interaction_start_size[1]) + + if dx > 0.1 or dy > 0.1 or dw > 0.1 or dh > 0.1: + from pyPhotoAlbum.commands import ResizeElementCommand + command = ResizeElementCommand( + element, + self._interaction_start_pos, + self._interaction_start_size, + new_pos, + new_size + ) + print(f"Resize command created: {self._interaction_start_size} → {new_size}") + + elif self._interaction_type == 'rotate': + # Check if rotation actually changed + new_rotation = element.rotation + if self._interaction_start_rotation is not None: + if abs(new_rotation - self._interaction_start_rotation) > 0.1: + from pyPhotoAlbum.commands import RotateElementCommand + command = RotateElementCommand( + element, + self._interaction_start_rotation, + new_rotation + ) + print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°") + + # Execute the command through history if one was created + if command: + main_window.project.history.execute(command) + + # Clear interaction state + self._clear_interaction_state() + + def _clear_interaction_state(self): + """Clear all interaction tracking state""" + self._interaction_element = None + self._interaction_type = None + self._interaction_start_pos = None + self._interaction_start_size = None + self._interaction_start_rotation = None + + def _cancel_interaction(self): + """Cancel the current interaction without creating a command""" + self._clear_interaction_state() diff --git a/pyPhotoAlbum/mixins/operations/__init__.py b/pyPhotoAlbum/mixins/operations/__init__.py index c41fb2c..2b376c0 100644 --- a/pyPhotoAlbum/mixins/operations/__init__.py +++ b/pyPhotoAlbum/mixins/operations/__init__.py @@ -11,6 +11,7 @@ from pyPhotoAlbum.mixins.operations.view_ops import ViewOperationsMixin from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixin from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin +from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin __all__ = [ 'FileOperationsMixin', @@ -22,4 +23,5 @@ __all__ = [ 'AlignmentOperationsMixin', 'DistributionOperationsMixin', 'SizeOperationsMixin', + 'ZOrderOperationsMixin', ] diff --git a/pyPhotoAlbum/mixins/operations/edit_ops.py b/pyPhotoAlbum/mixins/operations/edit_ops.py index aa89865..b952177 100644 --- a/pyPhotoAlbum/mixins/operations/edit_ops.py +++ b/pyPhotoAlbum/mixins/operations/edit_ops.py @@ -48,6 +48,7 @@ class EditOperationsMixin: tooltip="Delete selected element (Delete key)", tab="Home", group="Edit", + shortcut="Delete", requires_selection=True ) def delete_selected_element(self): diff --git a/pyPhotoAlbum/mixins/operations/template_ops.py b/pyPhotoAlbum/mixins/operations/template_ops.py index 19ba17f..6df9467 100644 --- a/pyPhotoAlbum/mixins/operations/template_ops.py +++ b/pyPhotoAlbum/mixins/operations/template_ops.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import ( QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox, QRadioButton, QButtonGroup, QPushButton, QHBoxLayout ) -from pyPhotoAlbum.decorators import ribbon_action +from pyPhotoAlbum.decorators import ribbon_action, undoable_operation class TemplateOperationsMixin: @@ -137,6 +137,7 @@ class TemplateOperationsMixin: group="Templates", requires_page=True ) + @undoable_operation(capture='page_elements', description='Apply Template') def apply_template_to_page(self): """Apply a template to the current page""" current_page = self.get_current_page() diff --git a/pyPhotoAlbum/mixins/operations/zorder_ops.py b/pyPhotoAlbum/mixins/operations/zorder_ops.py new file mode 100644 index 0000000..10c0d3c --- /dev/null +++ b/pyPhotoAlbum/mixins/operations/zorder_ops.py @@ -0,0 +1,199 @@ +""" +Z-order operations mixin for pyPhotoAlbum +""" + +from pyPhotoAlbum.decorators import ribbon_action +from pyPhotoAlbum.commands import ChangeZOrderCommand + + +class ZOrderOperationsMixin: + """Mixin providing z-order/layer control operations""" + + @ribbon_action( + label="Bring to Front", + tooltip="Bring selected element to front", + tab="Arrange", + group="Order", + shortcut="Ctrl+Shift+]", + requires_selection=True + ) + def bring_to_front(self): + """Bring selected element to front (end of list)""" + if not self.gl_widget.selected_element: + return + + current_page = self.get_current_page() + if not current_page: + return + + element = self.gl_widget.selected_element + elements = current_page.layout.elements + + if element not in elements: + return + + old_index = elements.index(element) + new_index = len(elements) - 1 + + if old_index == new_index: + self.show_status("Element is already at front", 2000) + return + + # Create and execute command + cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) + self.project.history.execute(cmd) + + self.update_view() + self.show_status("Brought element to front (Ctrl+Z to undo)", 2000) + print(f"Brought element to front: {old_index} → {new_index}") + + @ribbon_action( + label="Send to Back", + tooltip="Send selected element to back", + tab="Arrange", + group="Order", + shortcut="Ctrl+Shift+[", + requires_selection=True + ) + def send_to_back(self): + """Send selected element to back (start of list)""" + if not self.gl_widget.selected_element: + return + + current_page = self.get_current_page() + if not current_page: + return + + element = self.gl_widget.selected_element + elements = current_page.layout.elements + + if element not in elements: + return + + old_index = elements.index(element) + new_index = 0 + + if old_index == new_index: + self.show_status("Element is already at back", 2000) + return + + # Create and execute command + cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) + self.project.history.execute(cmd) + + self.update_view() + self.show_status("Sent element to back (Ctrl+Z to undo)", 2000) + print(f"Sent element to back: {old_index} → {new_index}") + + @ribbon_action( + label="Bring Forward", + tooltip="Bring selected element forward one layer", + tab="Arrange", + group="Order", + shortcut="Ctrl+]", + requires_selection=True + ) + def bring_forward(self): + """Move selected element forward one position in list""" + if not self.gl_widget.selected_element: + return + + current_page = self.get_current_page() + if not current_page: + return + + element = self.gl_widget.selected_element + elements = current_page.layout.elements + + if element not in elements: + return + + old_index = elements.index(element) + new_index = old_index + 1 + + if new_index >= len(elements): + self.show_status("Element is already at front", 2000) + return + + # Create and execute command + cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) + self.project.history.execute(cmd) + + self.update_view() + self.show_status("Brought element forward (Ctrl+Z to undo)", 2000) + print(f"Brought element forward: {old_index} → {new_index}") + + @ribbon_action( + label="Send Backward", + tooltip="Send selected element backward one layer", + tab="Arrange", + group="Order", + shortcut="Ctrl+[", + requires_selection=True + ) + def send_backward(self): + """Move selected element backward one position in list""" + if not self.gl_widget.selected_element: + return + + current_page = self.get_current_page() + if not current_page: + return + + element = self.gl_widget.selected_element + elements = current_page.layout.elements + + if element not in elements: + return + + old_index = elements.index(element) + new_index = old_index - 1 + + if new_index < 0: + self.show_status("Element is already at back", 2000) + return + + # Create and execute command + cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) + self.project.history.execute(cmd) + + self.update_view() + self.show_status("Sent element backward (Ctrl+Z to undo)", 2000) + print(f"Sent element backward: {old_index} → {new_index}") + + @ribbon_action( + label="Swap Order", + tooltip="Swap z-order of two selected elements", + tab="Arrange", + group="Order", + shortcut="Ctrl+Shift+X", + requires_selection=True, + min_selection=2 + ) + def swap_order(self): + """Swap the z-order of two selected elements""" + if len(self.gl_widget.selected_elements) != 2: + self.show_status("Please select exactly 2 elements to swap", 2000) + return + + current_page = self.get_current_page() + if not current_page: + return + + elements = current_page.layout.elements + selected = list(self.gl_widget.selected_elements) + + # Get indices of both elements + try: + index1 = elements.index(selected[0]) + index2 = elements.index(selected[1]) + except ValueError: + self.show_status("Selected elements not found on current page", 2000) + return + + # Swap them in the list + elements[index1], elements[index2] = elements[index2], elements[index1] + + self.update_view() + self.show_status(f"Swapped z-order of elements", 2000) + print(f"Swapped elements at indices {index1} and {index2}") diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index 4b5adc2..d6d855d 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -88,8 +88,8 @@ class PageLayout: glVertex2f(page_x, page_y + height_px) glEnd() - # Render elements in z-order - they're already in page-local coordinates - for element in sorted(self.elements, key=lambda x: x.z_index): + # Render elements in list order (list position = z-order) + for element in self.elements: element.render() # Draw page border LAST (on top of everything) @@ -184,7 +184,9 @@ class PageLayout: self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0))) self.elements = [] - # Deserialize elements + # Deserialize elements and sort by z_index to establish list order + # This ensures backward compatibility with projects that used z_index + elem_list = [] for elem_data in data.get("elements", []): elem_type = elem_data.get("type") if elem_type == "image": @@ -197,7 +199,11 @@ class PageLayout: continue elem.deserialize(elem_data) - self.elements.append(elem) + elem_list.append(elem) + + # Sort by z_index to establish proper list order (lower z_index = earlier in list = behind) + elem_list.sort(key=lambda e: e.z_index) + self.elements = elem_list # Deserialize grid layout grid_data = data.get("grid_layout") diff --git a/tests/test_zorder.py b/tests/test_zorder.py new file mode 100644 index 0000000..cb0b3b9 --- /dev/null +++ b/tests/test_zorder.py @@ -0,0 +1,381 @@ +""" +Unit tests for z-order operations in pyPhotoAlbum +""" + +import pytest +from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData +from pyPhotoAlbum.page_layout import PageLayout +from pyPhotoAlbum.commands import ChangeZOrderCommand, CommandHistory + + +class TestZOrderBasics: + """Tests for basic z-order functionality""" + + def test_list_order_is_render_order(self): + """Test that list order determines render order""" + layout = PageLayout(width=210, height=297) + + # Add elements in order + elem1 = ImageData(x=10, y=10, width=50, height=50) + elem2 = TextBoxData(x=20, y=20, width=50, height=50) + elem3 = PlaceholderData(x=30, y=30, width=50, height=50) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Verify order + assert layout.elements[0] is elem1 + assert layout.elements[1] is elem2 + assert layout.elements[2] is elem3 + + def test_element_at_end_renders_on_top(self): + """Test that element at end of list renders on top""" + layout = PageLayout(width=210, height=297) + + elem1 = ImageData(x=10, y=10) + elem2 = ImageData(x=20, y=20) + + layout.add_element(elem1) + layout.add_element(elem2) + + # elem2 should be last (on top) + assert layout.elements[-1] is elem2 + assert layout.elements.index(elem2) > layout.elements.index(elem1) + + +class TestChangeZOrderCommand: + """Tests for ChangeZOrderCommand""" + + def test_move_element_forward(self): + """Test moving an element forward one position""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Move elem1 forward (swap with elem2) + cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1) + cmd.execute() + + assert layout.elements.index(elem1) == 1 + assert layout.elements.index(elem2) == 0 + assert layout.elements.index(elem3) == 2 + + def test_move_element_backward(self): + """Test moving an element backward one position""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Move elem2 backward (swap with elem1) + cmd = ChangeZOrderCommand(layout, elem2, old_index=1, new_index=0) + cmd.execute() + + assert layout.elements.index(elem2) == 0 + assert layout.elements.index(elem1) == 1 + assert layout.elements.index(elem3) == 2 + + def test_move_to_front(self): + """Test moving an element to the front (end of list)""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Move elem1 to front + cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) + cmd.execute() + + assert layout.elements[-1] is elem1 + assert layout.elements.index(elem1) == 2 + + def test_move_to_back(self): + """Test moving an element to the back (start of list)""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Move elem3 to back + cmd = ChangeZOrderCommand(layout, elem3, old_index=2, new_index=0) + cmd.execute() + + assert layout.elements[0] is elem3 + assert layout.elements.index(elem3) == 0 + + def test_undo_redo(self): + """Test undo/redo functionality""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + original_order = list(layout.elements) + + # Move elem1 forward + cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1) + cmd.execute() + + assert layout.elements.index(elem1) == 1 + + # Undo + cmd.undo() + assert layout.elements == original_order + + # Redo + cmd.redo() + assert layout.elements.index(elem1) == 1 + + def test_command_with_history(self): + """Test ChangeZOrderCommand with CommandHistory""" + layout = PageLayout() + history = CommandHistory() + + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Execute command through history + cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) + history.execute(cmd) + + assert layout.elements.index(elem1) == 2 + assert history.can_undo() + + # Undo through history + history.undo() + assert layout.elements.index(elem1) == 0 + assert history.can_redo() + + # Redo through history + history.redo() + assert layout.elements.index(elem1) == 2 + + +class TestZOrderSerialization: + """Tests for z-order serialization and deserialization""" + + def test_serialize_preserves_order(self): + """Test that serialization preserves element order""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10, z_index=0) + elem2 = TextBoxData(x=20, y=20, z_index=1) + elem3 = PlaceholderData(x=30, y=30, z_index=2) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Serialize + data = layout.serialize() + + # Elements should be in order + assert len(data['elements']) == 3 + assert data['elements'][0]['type'] == 'image' + assert data['elements'][1]['type'] == 'textbox' + assert data['elements'][2]['type'] == 'placeholder' + + def test_deserialize_sorts_by_zindex(self): + """Test that deserialization sorts by z_index for backward compatibility""" + layout = PageLayout() + + # Create data with z_index values out of order + data = { + 'size': (210, 297), + 'base_width': 210, + 'is_facing_page': False, + 'background_color': (1.0, 1.0, 1.0), + 'elements': [ + {'type': 'image', 'position': (10, 10), 'size': (50, 50), + 'rotation': 0, 'z_index': 2, 'image_path': '', 'crop_info': (0, 0, 1, 1)}, + {'type': 'textbox', 'position': (20, 20), 'size': (50, 50), + 'rotation': 0, 'z_index': 0, 'text_content': '', + 'font_settings': {}, 'alignment': 'left'}, + {'type': 'placeholder', 'position': (30, 30), 'size': (50, 50), + 'rotation': 0, 'z_index': 1, 'placeholder_type': 'image', 'default_content': ''}, + ] + } + + layout.deserialize(data) + + # Elements should be sorted by z_index + assert len(layout.elements) == 3 + assert isinstance(layout.elements[0], TextBoxData) # z_index=0 + assert isinstance(layout.elements[1], PlaceholderData) # z_index=1 + assert isinstance(layout.elements[2], ImageData) # z_index=2 + + def test_roundtrip_maintains_order(self): + """Test that serialize/deserialize maintains element order""" + layout1 = PageLayout() + elem1 = ImageData(x=10, y=10, z_index=0) + elem2 = TextBoxData(x=20, y=20, z_index=1) + elem3 = PlaceholderData(x=30, y=30, z_index=2) + + layout1.add_element(elem1) + layout1.add_element(elem2) + layout1.add_element(elem3) + + # Serialize and deserialize + data = layout1.serialize() + layout2 = PageLayout() + layout2.deserialize(data) + + # Order should be maintained + assert len(layout2.elements) == 3 + assert isinstance(layout2.elements[0], ImageData) + assert isinstance(layout2.elements[1], TextBoxData) + assert isinstance(layout2.elements[2], PlaceholderData) + + +class TestZOrderEdgeCases: + """Tests for z-order edge cases""" + + def test_single_element(self): + """Test operations with single element""" + layout = PageLayout() + elem = ImageData(x=10, y=10) + layout.add_element(elem) + + # Try to move forward (should stay at index 0) + cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=0) + cmd.execute() + + assert layout.elements.index(elem) == 0 + + def test_empty_list(self): + """Test operations with empty list""" + layout = PageLayout() + assert len(layout.elements) == 0 + + def test_move_to_same_position(self): + """Test moving element to its current position""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + + layout.add_element(elem1) + layout.add_element(elem2) + + # Move to same position + cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=0) + cmd.execute() + + assert layout.elements.index(elem1) == 0 + assert layout.elements.index(elem2) == 1 + + def test_swap_adjacent_elements(self): + """Test swapping two adjacent elements""" + layout = PageLayout() + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + + layout.add_element(elem1) + layout.add_element(elem2) + + # Swap by moving elem1 forward + elements = layout.elements + index1 = elements.index(elem1) + index2 = elements.index(elem2) + elements[index1], elements[index2] = elements[index2], elements[index1] + + assert layout.elements[0] is elem2 + assert layout.elements[1] is elem1 + + def test_multiple_zorder_changes(self): + """Test multiple z-order changes in sequence""" + layout = PageLayout() + history = CommandHistory() + + elem1 = ImageData(x=10, y=10) + elem2 = TextBoxData(x=20, y=20) + elem3 = PlaceholderData(x=30, y=30) + + layout.add_element(elem1) + layout.add_element(elem2) + layout.add_element(elem3) + + # Move elem1 to front + cmd1 = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2) + history.execute(cmd1) + assert layout.elements.index(elem1) == 2 + + # Move elem2 to front + cmd2 = ChangeZOrderCommand(layout, elem2, old_index=0, new_index=2) + history.execute(cmd2) + assert layout.elements.index(elem2) == 2 + + # Undo both + history.undo() + assert layout.elements.index(elem2) == 0 + + history.undo() + assert layout.elements.index(elem1) == 0 + + +class TestZOrderCommandSerialization: + """Tests for ChangeZOrderCommand serialization""" + + def test_serialize_command(self): + """Test serializing a ChangeZOrderCommand""" + layout = PageLayout() + elem = ImageData(x=10, y=10) + layout.add_element(elem) + + cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=1) + + data = cmd.serialize() + + assert data['type'] == 'change_zorder' + assert data['old_index'] == 0 + assert data['new_index'] == 1 + assert 'element' in data + + def test_deserialize_command(self): + """Test deserializing a ChangeZOrderCommand""" + data = { + 'type': 'change_zorder', + 'element': { + 'type': 'image', + 'position': (10, 10), + 'size': (50, 50), + 'rotation': 0, + 'z_index': 0, + 'image_path': '', + 'crop_info': (0, 0, 1, 1) + }, + 'old_index': 0, + 'new_index': 1 + } + + cmd = ChangeZOrderCommand.deserialize(data, None) + + assert isinstance(cmd, ChangeZOrderCommand) + assert cmd.old_index == 0 + assert cmd.new_index == 1 + assert isinstance(cmd.element, ImageData)