Duncan Tourolle e93a789524 Added autosave
Fixed unusual bug where scrolling while draging content results in desyned movements
2025-11-22 22:29:05 +01:00

855 lines
28 KiB
Python

"""
Command pattern implementation for undo/redo functionality
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
def _normalize_asset_path(image_path: str, asset_manager) -> str:
"""
Convert absolute path to relative for asset manager.
Args:
image_path: Image path (absolute or relative)
asset_manager: AssetManager instance
Returns:
Relative path suitable for asset manager
"""
if not asset_manager or not image_path:
return image_path
import os
if os.path.isabs(image_path):
return os.path.relpath(image_path, asset_manager.project_folder)
return image_path
class Command(ABC):
"""Abstract base class for all commands"""
@abstractmethod
def execute(self):
"""Execute the command"""
pass
@abstractmethod
def undo(self):
"""Undo the command"""
pass
@abstractmethod
def redo(self):
"""Redo the command (default implementation calls execute)"""
self.execute()
@abstractmethod
def serialize(self) -> Dict[str, Any]:
"""Serialize command to dictionary for saving"""
pass
@staticmethod
@abstractmethod
def deserialize(data: Dict[str, Any], project) -> 'Command':
"""Deserialize command from dictionary"""
pass
class AddElementCommand(Command):
"""Command for adding an element to a page"""
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout
self.element = element
self.executed = False
self.asset_manager = asset_manager
# Acquire reference to asset when command is created
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
self.asset_manager.acquire_reference(rel_path)
def execute(self):
"""Add the element to the page"""
if not self.executed:
self.page_layout.add_element(self.element)
self.executed = True
def undo(self):
"""Remove the element from the page"""
if self.executed:
self.page_layout.remove_element(self.element)
self.executed = False
def redo(self):
"""Re-add the element"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "add_element",
"element": self.element.serialize(),
"executed": self.executed
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
"""Deserialize from dictionary"""
# Reconstruct element from serialized data
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)
# Note: We need to find the correct page_layout
# This will be handled by the CommandHistory deserializer
cmd = AddElementCommand(None, element)
cmd.executed = data.get("executed", False)
return cmd
class DeleteElementCommand(Command):
"""Command for deleting an element from a page"""
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout
self.element = element
self.executed = False
self.asset_manager = asset_manager
# Acquire reference to asset to keep it alive while in undo history
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
self.asset_manager.acquire_reference(rel_path)
def execute(self):
"""Remove the element from the page"""
if not self.executed:
self.page_layout.remove_element(self.element)
self.executed = True
def undo(self):
"""Re-add the element to the page"""
if self.executed:
self.page_layout.add_element(self.element)
self.executed = False
def redo(self):
"""Re-remove the element"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "delete_element",
"element": self.element.serialize(),
"executed": self.executed
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
"""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)
cmd = DeleteElementCommand(None, element)
cmd.executed = data.get("executed", False)
return cmd
class MoveElementCommand(Command):
"""Command for moving an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
self.element = element
self.old_position = old_position
self.new_position = new_position
def execute(self):
"""Move element to new position"""
self.element.position = self.new_position
def undo(self):
"""Move element back to old position"""
self.element.position = self.old_position
def redo(self):
"""Move element to new position again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "move_element",
"element": self.element.serialize(),
"old_position": self.old_position,
"new_position": self.new_position
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
"""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 MoveElementCommand(
element,
tuple(data["old_position"]),
tuple(data["new_position"])
)
class ResizeElementCommand(Command):
"""Command for resizing an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
new_position: tuple, new_size: tuple):
self.element = element
self.old_position = old_position
self.old_size = old_size
self.new_position = new_position
self.new_size = new_size
def execute(self):
"""Resize element to new size"""
self.element.position = self.new_position
self.element.size = self.new_size
def undo(self):
"""Resize element back to old size"""
self.element.position = self.old_position
self.element.size = self.old_size
def redo(self):
"""Resize element to new size again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "resize_element",
"element": self.element.serialize(),
"old_position": self.old_position,
"old_size": self.old_size,
"new_position": self.new_position,
"new_size": self.new_size
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
"""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 ResizeElementCommand(
element,
tuple(data["old_position"]),
tuple(data["old_size"]),
tuple(data["new_position"]),
tuple(data["new_size"])
)
class RotateElementCommand(Command):
"""Command for rotating an element"""
def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):
self.element = element
self.old_rotation = old_rotation
self.new_rotation = new_rotation
# Store old position, size, and PIL rotation state
self.old_position = element.position
self.old_size = element.size
# For ImageData, store the old PIL rotation state
if hasattr(element, 'pil_rotation_90'):
self.old_pil_rotation = element.pil_rotation_90
else:
self.old_pil_rotation = None
def execute(self):
"""Rotate element by physically rotating the PIL image data"""
from pyPhotoAlbum.models import ImageData
# Calculate rotation delta
delta = (self.new_rotation - self.old_rotation) % 360
# For ImageData, rotate the actual PIL image
if isinstance(self.element, ImageData):
# Update PIL rotation counter
if delta == 90:
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4
elif delta == 270:
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4
elif delta == 180:
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4
# For 90° or 270° rotations, swap dimensions
if delta == 90 or delta == 270:
w, h = self.element.size
x, y = self.element.position
# Swap dimensions
self.element.size = (h, w)
# Adjust position to keep center in same place
center_x = x + w / 2
center_y = y + h / 2
self.element.position = (center_x - h / 2, center_y - w / 2)
# Clear the texture so it will be reloaded with the new rotation
if hasattr(self.element, '_texture_id'):
del self.element._texture_id
if hasattr(self.element, '_async_load_requested'):
self.element._async_load_requested = False
# Keep visual rotation at 0
self.element.rotation = 0
else:
# For non-image elements, use old visual rotation
if delta == 90 or delta == 270:
w, h = self.element.size
x, y = self.element.position
self.element.size = (h, w)
center_x = x + w / 2
center_y = y + h / 2
self.element.position = (center_x - h / 2, center_y - w / 2)
self.element.rotation = 0
else:
self.element.rotation = self.new_rotation
def undo(self):
"""Restore element back to old state"""
from pyPhotoAlbum.models import ImageData
# Restore original rotation, position, and size
self.element.rotation = self.old_rotation
self.element.position = self.old_position
self.element.size = self.old_size
# For ImageData, restore PIL rotation and clear texture
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
self.element.pil_rotation_90 = self.old_pil_rotation
if hasattr(self.element, '_texture_id'):
self.element._texture_id = None
self.element._async_load_requested = False
def redo(self):
"""Rotate element to new angle again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "rotate_element",
"element": self.element.serialize(),
"old_rotation": self.old_rotation,
"new_rotation": self.new_rotation
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
"""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 RotateElementCommand(
element,
data["old_rotation"],
data["new_rotation"]
)
class AdjustImageCropCommand(Command):
"""Command for adjusting image crop/pan within frame"""
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
self.element = element
self.old_crop_info = old_crop_info
self.new_crop_info = new_crop_info
def execute(self):
"""Apply new crop info"""
self.element.crop_info = self.new_crop_info
def undo(self):
"""Restore old crop info"""
self.element.crop_info = self.old_crop_info
def redo(self):
"""Apply new crop info again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
return AdjustImageCropCommand(
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
class AlignElementsCommand(Command):
"""Command for aligning multiple elements"""
def __init__(self, changes: List[tuple]):
"""
Args:
changes: List of (element, old_position) tuples
"""
self.changes = changes
def execute(self):
"""Positions have already been set by AlignmentManager"""
pass
def undo(self):
"""Restore old positions"""
for element, old_position in self.changes:
element.position = old_position
def redo(self):
"""Re-apply alignment (positions are stored in current state)"""
# Store current positions and restore them
new_positions = [(elem, elem.position) for elem, _ in self.changes]
for element, old_position in self.changes:
element.position = old_position
# Then re-apply new positions
for element, new_position in new_positions:
element.position = new_position
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "align_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos
}
for elem, old_pos in self.changes
]
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_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:
continue
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
changes.append((element, old_position))
return AlignElementsCommand(changes)
class ResizeElementsCommand(Command):
"""Command for resizing multiple elements"""
def __init__(self, changes: List[tuple]):
"""
Args:
changes: List of (element, old_position, old_size) tuples
"""
self.changes = changes
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
def execute(self):
"""Sizes have already been set by AlignmentManager"""
pass
def undo(self):
"""Restore old positions and sizes"""
for element, old_position, old_size in self.changes:
element.position = old_position
element.size = old_size
def redo(self):
"""Re-apply new sizes"""
for element, new_position, new_size in self.new_states:
element.position = new_position
element.size = new_size
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "resize_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos,
"old_size": old_size
}
for elem, old_pos, old_size in self.changes
]
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_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:
continue
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
old_size = tuple(change_data["old_size"])
changes.append((element, old_position, old_size))
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"""
def __init__(self, max_history: int = 100, asset_manager=None, project=None):
self.undo_stack: List[Command] = []
self.redo_stack: List[Command] = []
self.max_history = max_history
self.asset_manager = asset_manager
self.project = project # Reference to project for dirty flag tracking
def execute(self, command: Command):
"""Execute a command and add it to history"""
command.execute()
# When clearing redo stack, release asset references
for cmd in self.redo_stack:
self._release_command_assets(cmd)
self.redo_stack.clear()
self.undo_stack.append(command)
# Mark project as dirty
if self.project:
self.project.mark_dirty()
# Limit history size - release assets from old commands
if len(self.undo_stack) > self.max_history:
old_cmd = self.undo_stack.pop(0)
self._release_command_assets(old_cmd)
def _release_command_assets(self, command: Command):
"""Release asset references held by a command"""
if not self.asset_manager:
return
# Release asset references for commands that hold them
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
if isinstance(command.element, ImageData) and command.element.image_path:
# Convert absolute path to relative for asset manager
import os
asset_path = command.element.image_path
if os.path.isabs(asset_path):
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
self.asset_manager.release_reference(asset_path)
def undo(self) -> bool:
"""Undo the last command"""
if not self.can_undo():
return False
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
# Mark project as dirty
if self.project:
self.project.mark_dirty()
return True
def redo(self) -> bool:
"""Redo the last undone command"""
if not self.can_redo():
return False
command = self.redo_stack.pop()
command.redo()
self.undo_stack.append(command)
# Mark project as dirty
if self.project:
self.project.mark_dirty()
return True
def can_undo(self) -> bool:
"""Check if undo is available"""
return len(self.undo_stack) > 0
def can_redo(self) -> bool:
"""Check if redo is available"""
return len(self.redo_stack) > 0
def clear(self):
"""Clear all history and release asset references"""
# Release all asset references
for cmd in self.undo_stack:
self._release_command_assets(cmd)
for cmd in self.redo_stack:
self._release_command_assets(cmd)
self.undo_stack.clear()
self.redo_stack.clear()
def serialize(self) -> Dict[str, Any]:
"""Serialize history to dictionary"""
return {
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
"max_history": self.max_history
}
def deserialize(self, data: Dict[str, Any], project):
"""Deserialize history from dictionary"""
self.max_history = data.get("max_history", 100)
# Deserialize undo stack
self.undo_stack = []
for cmd_data in data.get("undo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
self.undo_stack.append(cmd)
# Deserialize redo stack
self.redo_stack = []
for cmd_data in data.get("redo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
self.redo_stack.append(cmd)
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
"""Deserialize a single command"""
cmd_type = data.get("type")
try:
if cmd_type == "add_element":
return AddElementCommand.deserialize(data, project)
elif cmd_type == "delete_element":
return DeleteElementCommand.deserialize(data, project)
elif cmd_type == "move_element":
return MoveElementCommand.deserialize(data, project)
elif cmd_type == "resize_element":
return ResizeElementCommand.deserialize(data, project)
elif cmd_type == "rotate_element":
return RotateElementCommand.deserialize(data, project)
elif cmd_type == "align_elements":
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)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else:
print(f"Warning: Unknown command type: {cmd_type}")
return None
except Exception as e:
print(f"Error deserializing command: {e}")
return None