Coverage for pyPhotoAlbum/commands.py: 90%
384 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Command pattern implementation for undo/redo functionality
3"""
5import os
6from abc import ABC, abstractmethod
7from typing import Dict, Any, List, Optional
8from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
11def _normalize_asset_path(image_path: str, asset_manager) -> str:
12 """
13 Convert absolute path to relative for asset manager.
15 Args:
16 image_path: Image path (absolute or relative)
17 asset_manager: AssetManager instance
19 Returns:
20 Relative path suitable for asset manager
21 """
22 if not asset_manager or not image_path:
23 return image_path
25 if os.path.isabs(image_path):
26 return os.path.relpath(image_path, asset_manager.project_folder)
27 return image_path
30def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
31 """
32 Deserialize element data into the appropriate element type.
34 Args:
35 elem_data: Dictionary containing serialized element data with 'type' key
37 Returns:
38 Deserialized element instance (ImageData, PlaceholderData, or TextBoxData)
40 Raises:
41 ValueError: If element type is unknown
42 """
43 elem_type = elem_data.get("type")
45 element: BaseLayoutElement
46 if elem_type == "image":
47 element = ImageData()
48 elif elem_type == "placeholder":
49 element = PlaceholderData()
50 elif elem_type == "textbox":
51 element = TextBoxData()
52 else:
53 raise ValueError(f"Unknown element type: {elem_type}")
55 element.deserialize(elem_data)
56 return element
59class Command(ABC):
60 """Abstract base class for all commands"""
62 @abstractmethod
63 def execute(self):
64 """Execute the command"""
65 pass
67 @abstractmethod
68 def undo(self):
69 """Undo the command"""
70 pass
72 @abstractmethod
73 def redo(self):
74 """Redo the command (default implementation calls execute)"""
75 self.execute()
77 @abstractmethod
78 def serialize(self) -> Dict[str, Any]:
79 """Serialize command to dictionary for saving"""
80 pass
82 @staticmethod
83 @abstractmethod
84 def deserialize(data: Dict[str, Any], project) -> "Command":
85 """Deserialize command from dictionary"""
86 pass
89class AddElementCommand(Command):
90 """Command for adding an element to a page"""
92 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
93 self.page_layout = page_layout
94 self.element = element
95 self.executed = False
96 self.asset_manager = asset_manager
98 # Acquire reference to asset when command is created
99 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
100 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
101 self.asset_manager.acquire_reference(rel_path)
103 def execute(self):
104 """Add the element to the page"""
105 if not self.executed:
106 self.page_layout.add_element(self.element)
107 self.executed = True
109 def undo(self):
110 """Remove the element from the page"""
111 if self.executed:
112 self.page_layout.remove_element(self.element)
113 self.executed = False
115 def redo(self):
116 """Re-add the element"""
117 self.execute()
119 def serialize(self) -> Dict[str, Any]:
120 """Serialize to dictionary"""
121 return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
123 @staticmethod
124 def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
125 """Deserialize from dictionary"""
126 element = _deserialize_element(data["element"])
127 # Note: page_layout will be handled by the CommandHistory deserializer
128 cmd = AddElementCommand(None, element)
129 cmd.executed = data.get("executed", False)
130 return cmd
133class DeleteElementCommand(Command):
134 """Command for deleting an element from a page"""
136 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
137 self.page_layout = page_layout
138 self.element = element
139 self.executed = False
140 self.asset_manager = asset_manager
142 # Acquire reference to asset to keep it alive while in undo history
143 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
144 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
145 self.asset_manager.acquire_reference(rel_path)
147 def execute(self):
148 """Remove the element from the page"""
149 if not self.executed:
150 self.page_layout.remove_element(self.element)
151 self.executed = True
153 def undo(self):
154 """Re-add the element to the page"""
155 if self.executed:
156 self.page_layout.add_element(self.element)
157 self.executed = False
159 def redo(self):
160 """Re-remove the element"""
161 self.execute()
163 def serialize(self) -> Dict[str, Any]:
164 """Serialize to dictionary"""
165 return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
167 @staticmethod
168 def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
169 """Deserialize from dictionary"""
170 element = _deserialize_element(data["element"])
171 cmd = DeleteElementCommand(None, element)
172 cmd.executed = data.get("executed", False)
173 return cmd
176class MoveElementCommand(Command):
177 """Command for moving an element"""
179 def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
180 self.element = element
181 self.old_position = old_position
182 self.new_position = new_position
184 def execute(self):
185 """Move element to new position"""
186 self.element.position = self.new_position
188 def undo(self):
189 """Move element back to old position"""
190 self.element.position = self.old_position
192 def redo(self):
193 """Move element to new position again"""
194 self.execute()
196 def serialize(self) -> Dict[str, Any]:
197 """Serialize to dictionary"""
198 return {
199 "type": "move_element",
200 "element": self.element.serialize(),
201 "old_position": self.old_position,
202 "new_position": self.new_position,
203 }
205 @staticmethod
206 def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
207 """Deserialize from dictionary"""
208 element = _deserialize_element(data["element"])
209 return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
212class ResizeElementCommand(Command):
213 """Command for resizing an element"""
215 def __init__(
216 self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
217 ):
218 self.element = element
219 self.old_position = old_position
220 self.old_size = old_size
221 self.new_position = new_position
222 self.new_size = new_size
224 def execute(self):
225 """Resize element to new size"""
226 self.element.position = self.new_position
227 self.element.size = self.new_size
229 def undo(self):
230 """Resize element back to old size"""
231 self.element.position = self.old_position
232 self.element.size = self.old_size
234 def redo(self):
235 """Resize element to new size again"""
236 self.execute()
238 def serialize(self) -> Dict[str, Any]:
239 """Serialize to dictionary"""
240 return {
241 "type": "resize_element",
242 "element": self.element.serialize(),
243 "old_position": self.old_position,
244 "old_size": self.old_size,
245 "new_position": self.new_position,
246 "new_size": self.new_size,
247 }
249 @staticmethod
250 def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
251 """Deserialize from dictionary"""
252 element = _deserialize_element(data["element"])
253 return ResizeElementCommand(
254 element,
255 tuple(data["old_position"]),
256 tuple(data["old_size"]),
257 tuple(data["new_position"]),
258 tuple(data["new_size"]),
259 )
262class RotateElementCommand(Command):
263 """Command for rotating an element"""
265 def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):
266 self.element = element
267 self.old_rotation = old_rotation
268 self.new_rotation = new_rotation
270 # Store old position, size, and PIL rotation state
271 self.old_position = element.position
272 self.old_size = element.size
274 # For ImageData, store the old PIL rotation state
275 if hasattr(element, "pil_rotation_90"):
276 self.old_pil_rotation = element.pil_rotation_90
277 else:
278 self.old_pil_rotation = None
280 def execute(self):
281 """Rotate element by physically rotating the PIL image data"""
282 from pyPhotoAlbum.models import ImageData
284 # Calculate rotation delta
285 delta = (self.new_rotation - self.old_rotation) % 360
287 # For ImageData, rotate the actual PIL image
288 if isinstance(self.element, ImageData):
289 # Update PIL rotation counter
290 if delta == 90:
291 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4
292 elif delta == 270:
293 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4
294 elif delta == 180:
295 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4
297 # For 90° or 270° rotations, swap dimensions
298 if delta == 90 or delta == 270:
299 w, h = self.element.size
300 x, y = self.element.position
302 # Swap dimensions
303 self.element.size = (h, w)
305 # Adjust position to keep center in same place
306 center_x = x + w / 2
307 center_y = y + h / 2
308 self.element.position = (center_x - h / 2, center_y - w / 2)
310 # Clear the texture so it will be reloaded with the new rotation
311 if hasattr(self.element, "_texture_id"):
312 del self.element._texture_id
313 if hasattr(self.element, "_async_load_requested"):
314 self.element._async_load_requested = False
316 # Keep visual rotation at 0
317 self.element.rotation = 0
318 else:
319 # For non-image elements, use old visual rotation
320 if delta == 90 or delta == 270:
321 w, h = self.element.size
322 x, y = self.element.position
323 self.element.size = (h, w)
324 center_x = x + w / 2
325 center_y = y + h / 2
326 self.element.position = (center_x - h / 2, center_y - w / 2)
327 self.element.rotation = 0
328 else:
329 self.element.rotation = self.new_rotation
331 def undo(self):
332 """Restore element back to old state"""
333 from pyPhotoAlbum.models import ImageData
335 # Restore original rotation, position, and size
336 self.element.rotation = self.old_rotation
337 self.element.position = self.old_position
338 self.element.size = self.old_size
340 # For ImageData, restore PIL rotation and clear texture
341 if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
342 self.element.pil_rotation_90 = self.old_pil_rotation
343 if hasattr(self.element, "_texture_id"):
344 self.element._texture_id = None
345 self.element._async_load_requested = False
347 def redo(self):
348 """Rotate element to new angle again"""
349 self.execute()
351 def serialize(self) -> Dict[str, Any]:
352 """Serialize to dictionary"""
353 return {
354 "type": "rotate_element",
355 "element": self.element.serialize(),
356 "old_rotation": self.old_rotation,
357 "new_rotation": self.new_rotation,
358 }
360 @staticmethod
361 def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
362 """Deserialize from dictionary"""
363 element = _deserialize_element(data["element"])
364 return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
367class AdjustImageCropCommand(Command):
368 """Command for adjusting image crop/pan within frame"""
370 def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
371 self.element = element
372 self.old_crop_info = old_crop_info
373 self.new_crop_info = new_crop_info
375 def execute(self):
376 """Apply new crop info"""
377 self.element.crop_info = self.new_crop_info
379 def undo(self):
380 """Restore old crop info"""
381 self.element.crop_info = self.old_crop_info
383 def redo(self):
384 """Apply new crop info again"""
385 self.execute()
387 def serialize(self) -> Dict[str, Any]:
388 """Serialize to dictionary"""
389 return {
390 "type": "adjust_image_crop",
391 "element": self.element.serialize(),
392 "old_crop_info": self.old_crop_info,
393 "new_crop_info": self.new_crop_info,
394 }
396 @staticmethod
397 def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
398 """Deserialize from dictionary"""
399 elem_data = data["element"]
400 element = ImageData()
401 element.deserialize(elem_data)
403 return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
406class AlignElementsCommand(Command):
407 """Command for aligning multiple elements"""
409 def __init__(self, changes: List[tuple]):
410 """
411 Args:
412 changes: List of (element, old_position) tuples
413 """
414 self.changes = changes
416 def execute(self):
417 """Positions have already been set by AlignmentManager"""
418 pass
420 def undo(self):
421 """Restore old positions"""
422 for element, old_position in self.changes:
423 element.position = old_position
425 def redo(self):
426 """Re-apply alignment (positions are stored in current state)"""
427 # Store current positions and restore them
428 new_positions = [(elem, elem.position) for elem, _ in self.changes]
429 for element, old_position in self.changes:
430 element.position = old_position
431 # Then re-apply new positions
432 for element, new_position in new_positions:
433 element.position = new_position
435 def serialize(self) -> Dict[str, Any]:
436 """Serialize to dictionary"""
437 return {
438 "type": "align_elements",
439 "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
440 }
442 @staticmethod
443 def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
444 """Deserialize from dictionary"""
445 changes = []
446 for change_data in data.get("changes", []):
447 try:
448 element = _deserialize_element(change_data["element"])
449 old_position = tuple(change_data["old_position"])
450 changes.append((element, old_position))
451 except ValueError:
452 continue
453 return AlignElementsCommand(changes)
456class ResizeElementsCommand(Command):
457 """Command for resizing multiple elements"""
459 def __init__(self, changes: List[tuple]):
460 """
461 Args:
462 changes: List of (element, old_position, old_size) tuples
463 """
464 self.changes = changes
465 self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
467 def execute(self):
468 """Sizes have already been set by AlignmentManager"""
469 pass
471 def undo(self):
472 """Restore old positions and sizes"""
473 for element, old_position, old_size in self.changes:
474 element.position = old_position
475 element.size = old_size
477 def redo(self):
478 """Re-apply new sizes"""
479 for element, new_position, new_size in self.new_states:
480 element.position = new_position
481 element.size = new_size
483 def serialize(self) -> Dict[str, Any]:
484 """Serialize to dictionary"""
485 return {
486 "type": "resize_elements",
487 "changes": [
488 {"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
489 for elem, old_pos, old_size in self.changes
490 ],
491 }
493 @staticmethod
494 def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
495 """Deserialize from dictionary"""
496 changes = []
497 for change_data in data.get("changes", []):
498 try:
499 element = _deserialize_element(change_data["element"])
500 old_position = tuple(change_data["old_position"])
501 old_size = tuple(change_data["old_size"])
502 changes.append((element, old_position, old_size))
503 except ValueError:
504 continue
505 return ResizeElementsCommand(changes)
508class ChangeZOrderCommand(Command):
509 """Command for changing element z-order (list position)"""
511 def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
512 self.page_layout = page_layout
513 self.element = element
514 self.old_index = old_index
515 self.new_index = new_index
517 def execute(self):
518 """Move element to new position in list"""
519 elements = self.page_layout.elements
520 if self.element in elements:
521 elements.remove(self.element)
522 elements.insert(self.new_index, self.element)
524 def undo(self):
525 """Move element back to old position in list"""
526 elements = self.page_layout.elements
527 if self.element in elements:
528 elements.remove(self.element)
529 elements.insert(self.old_index, self.element)
531 def redo(self):
532 """Move element to new position again"""
533 self.execute()
535 def serialize(self) -> Dict[str, Any]:
536 """Serialize to dictionary"""
537 return {
538 "type": "change_zorder",
539 "element": self.element.serialize(),
540 "old_index": self.old_index,
541 "new_index": self.new_index,
542 }
544 @staticmethod
545 def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
546 """Deserialize from dictionary"""
547 element = _deserialize_element(data["element"])
548 return ChangeZOrderCommand(
549 None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
550 )
553class StateChangeCommand(Command):
554 """
555 Generic command for operations that change state.
557 This command captures before/after snapshots of state and can restore them.
558 Used by the @undoable_operation decorator.
559 """
561 def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
562 """
563 Args:
564 description: Human-readable description of the operation
565 restore_func: Function to restore state: restore_func(state)
566 before_state: State before the operation
567 after_state: State after the operation (captured during execute)
568 """
569 self.description = description
570 self.restore_func = restore_func
571 self.before_state = before_state
572 self.after_state = after_state
574 def execute(self):
575 """State is already applied, just store after_state if not set"""
576 # After state is captured by decorator after operation runs
577 pass
579 def undo(self):
580 """Restore to before state"""
581 self.restore_func(self.before_state)
583 def redo(self):
584 """Restore to after state"""
585 self.restore_func(self.after_state)
587 def serialize(self) -> Dict[str, Any]:
588 """Serialize to dictionary"""
589 # For now, state change commands are not serialized
590 # This could be enhanced later if needed
591 return {"type": "state_change", "description": self.description}
593 @staticmethod
594 def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
595 """Deserialize from dictionary"""
596 # Not implemented - would need to serialize state
597 raise NotImplementedError("StateChangeCommand deserialization not yet supported")
600class CommandHistory:
601 """Manages undo/redo command history"""
603 def __init__(self, max_history: int = 100, asset_manager=None, project=None):
604 self.undo_stack: List[Command] = []
605 self.redo_stack: List[Command] = []
606 self.max_history = max_history
607 self.asset_manager = asset_manager
608 self.project = project # Reference to project for dirty flag tracking
610 def execute(self, command: Command):
611 """Execute a command and add it to history"""
612 command.execute()
614 # When clearing redo stack, release asset references
615 for cmd in self.redo_stack:
616 self._release_command_assets(cmd)
617 self.redo_stack.clear()
619 self.undo_stack.append(command)
621 # Mark project as dirty
622 if self.project:
623 self.project.mark_dirty()
625 # Limit history size - release assets from old commands
626 if len(self.undo_stack) > self.max_history:
627 old_cmd = self.undo_stack.pop(0)
628 self._release_command_assets(old_cmd)
630 def _release_command_assets(self, command: Command):
631 """Release asset references held by a command"""
632 if not self.asset_manager:
633 return
635 # Release asset references for commands that hold them
636 if isinstance(command, (AddElementCommand, DeleteElementCommand)):
637 if isinstance(command.element, ImageData) and command.element.image_path:
638 # Convert absolute path to relative for asset manager
639 asset_path = command.element.image_path
640 if os.path.isabs(asset_path):
641 asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
642 self.asset_manager.release_reference(asset_path)
644 def undo(self) -> bool:
645 """Undo the last command"""
646 if not self.can_undo():
647 return False
649 command = self.undo_stack.pop()
650 command.undo()
651 self.redo_stack.append(command)
653 # Mark project as dirty
654 if self.project:
655 self.project.mark_dirty()
657 return True
659 def redo(self) -> bool:
660 """Redo the last undone command"""
661 if not self.can_redo():
662 return False
664 command = self.redo_stack.pop()
665 command.redo()
666 self.undo_stack.append(command)
668 # Mark project as dirty
669 if self.project:
670 self.project.mark_dirty()
672 return True
674 def can_undo(self) -> bool:
675 """Check if undo is available"""
676 return len(self.undo_stack) > 0
678 def can_redo(self) -> bool:
679 """Check if redo is available"""
680 return len(self.redo_stack) > 0
682 def clear(self):
683 """Clear all history and release asset references"""
684 # Release all asset references
685 for cmd in self.undo_stack:
686 self._release_command_assets(cmd)
687 for cmd in self.redo_stack:
688 self._release_command_assets(cmd)
690 self.undo_stack.clear()
691 self.redo_stack.clear()
693 def serialize(self) -> Dict[str, Any]:
694 """Serialize history to dictionary"""
695 return {
696 "undo_stack": [cmd.serialize() for cmd in self.undo_stack],
697 "redo_stack": [cmd.serialize() for cmd in self.redo_stack],
698 "max_history": self.max_history,
699 }
701 def deserialize(self, data: Dict[str, Any], project):
702 """Deserialize history from dictionary"""
703 self.max_history = data.get("max_history", 100)
705 # Deserialize undo stack
706 self.undo_stack = []
707 for cmd_data in data.get("undo_stack", []):
708 cmd = self._deserialize_command(cmd_data, project)
709 if cmd:
710 # Fix up page_layout references for commands that need them
711 self._fixup_page_layout(cmd, project)
712 self.undo_stack.append(cmd)
714 # Deserialize redo stack
715 self.redo_stack = []
716 for cmd_data in data.get("redo_stack", []):
717 cmd = self._deserialize_command(cmd_data, project)
718 if cmd:
719 # Fix up page_layout references for commands that need them
720 self._fixup_page_layout(cmd, project)
721 self.redo_stack.append(cmd)
723 def _fixup_page_layout(self, cmd: Command, project):
724 """
725 Fix up page_layout references after deserialization.
727 Commands like AddElementCommand store page_layout as None during
728 deserialization because the page_layout object doesn't exist yet.
729 This method finds the correct page_layout based on the element.
730 """
731 # Check if command has a page_layout attribute that's None
732 if not hasattr(cmd, "page_layout") or cmd.page_layout is not None:
733 return
735 # Try to find the page containing this element
736 if hasattr(cmd, "element") and cmd.element:
737 element = cmd.element
738 for page in project.pages:
739 if element in page.layout.elements:
740 cmd.page_layout = page.layout
741 return
742 # Element not found in any page - use first page as fallback
743 # This can happen for newly added elements not yet in a page
744 if project.pages:
745 cmd.page_layout = project.pages[0].layout
747 # Command type registry for deserialization
748 _COMMAND_DESERIALIZERS = {
749 "add_element": AddElementCommand.deserialize,
750 "delete_element": DeleteElementCommand.deserialize,
751 "move_element": MoveElementCommand.deserialize,
752 "resize_element": ResizeElementCommand.deserialize,
753 "rotate_element": RotateElementCommand.deserialize,
754 "align_elements": AlignElementsCommand.deserialize,
755 "resize_elements": ResizeElementsCommand.deserialize,
756 "change_zorder": ChangeZOrderCommand.deserialize,
757 "adjust_image_crop": AdjustImageCropCommand.deserialize,
758 }
760 def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
761 """Deserialize a single command using registry pattern"""
762 cmd_type = data.get("type")
763 if cmd_type is None:
764 return None
766 deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
767 if not deserializer:
768 print(f"Warning: Unknown command type: {cmd_type}")
769 return None
771 try:
772 return deserializer(data, project)
773 except Exception as e:
774 print(f"Error deserializing command: {e}")
775 return None