Coverage for pyPhotoAlbum/mixins/interaction_command_builders.py: 89%
98 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 builders for different interaction types.
4Each builder is responsible for:
51. Validating if a command should be created
62. Creating the appropriate command object
73. Logging the operation
8"""
10from abc import ABC, abstractmethod
11from typing import Optional, Any
12from pyPhotoAlbum.models import BaseLayoutElement
13from .interaction_validators import InteractionChangeDetector
16class CommandBuilder(ABC):
17 """Base class for command builders."""
19 def __init__(self, change_detector: Optional[InteractionChangeDetector] = None):
20 self.change_detector = change_detector or InteractionChangeDetector()
22 @abstractmethod
23 def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
24 """
25 Check if a command should be built based on state changes.
27 Args:
28 element: The element being modified
29 start_state: Dict containing the initial state
30 **kwargs: Additional context
32 Returns:
33 True if a command should be created
34 """
35 pass
37 @abstractmethod
38 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
39 """
40 Build and return the command object.
42 Args:
43 element: The element being modified
44 start_state: Dict containing the initial state
45 **kwargs: Additional context
47 Returns:
48 Command object or None
49 """
50 pass
52 def log_command(self, command_type: str, details: str):
53 """Log command creation for debugging."""
54 print(f"{command_type} command created: {details}")
57class MoveCommandBuilder(CommandBuilder):
58 """Builds MoveElementCommand objects."""
60 def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
61 """Check if position changed significantly."""
62 old_pos = start_state.get("position")
63 if old_pos is None:
64 return False
66 new_pos = element.position
67 return self.change_detector.detect_position_change(old_pos, new_pos) is not None
69 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
70 """Build a MoveElementCommand."""
71 old_pos = start_state.get("position")
72 if old_pos is None:
73 return None
75 new_pos = element.position
76 change_info = self.change_detector.detect_position_change(old_pos, new_pos)
78 if change_info is None:
79 return None
81 from pyPhotoAlbum.commands import MoveElementCommand
83 command = MoveElementCommand(element, old_pos, new_pos)
85 self.log_command("Move", f"{old_pos} → {new_pos}")
86 return command
89class ResizeCommandBuilder(CommandBuilder):
90 """Builds ResizeElementCommand objects."""
92 def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
93 """Check if position or size changed significantly."""
94 old_pos = start_state.get("position")
95 old_size = start_state.get("size")
97 if old_pos is None or old_size is None:
98 return False
100 new_pos = element.position
101 new_size = element.size
103 pos_change = self.change_detector.detect_position_change(old_pos, new_pos)
104 size_change = self.change_detector.detect_size_change(old_size, new_size)
106 return pos_change is not None or size_change is not None
108 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
109 """Build a ResizeElementCommand."""
110 old_pos = start_state.get("position")
111 old_size = start_state.get("size")
113 if old_pos is None or old_size is None:
114 return None
116 new_pos = element.position
117 new_size = element.size
119 if not self.can_build(element, start_state):
120 return None
122 from pyPhotoAlbum.commands import ResizeElementCommand
124 command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
126 self.log_command("Resize", f"{old_size} → {new_size}")
127 return command
130class RotateCommandBuilder(CommandBuilder):
131 """Builds RotateElementCommand objects."""
133 def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
134 """Check if rotation changed significantly."""
135 old_rotation = start_state.get("rotation")
136 if old_rotation is None:
137 return False
139 new_rotation = element.rotation
140 return self.change_detector.detect_rotation_change(old_rotation, new_rotation) is not None
142 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
143 """Build a RotateElementCommand."""
144 old_rotation = start_state.get("rotation")
145 if old_rotation is None:
146 return None
148 new_rotation = element.rotation
149 change_info = self.change_detector.detect_rotation_change(old_rotation, new_rotation)
151 if change_info is None:
152 return None
154 from pyPhotoAlbum.commands import RotateElementCommand
156 command = RotateElementCommand(element, old_rotation, new_rotation)
158 self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
159 return command
162class ImagePanCommandBuilder(CommandBuilder):
163 """Builds AdjustImageCropCommand objects for image panning."""
165 def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
166 """Check if crop info changed significantly."""
167 from pyPhotoAlbum.models import ImageData
169 if not isinstance(element, ImageData):
170 return False
172 old_crop = start_state.get("crop_info")
173 if old_crop is None:
174 return False
176 new_crop = element.crop_info
177 change_detector = InteractionChangeDetector(threshold=0.001)
178 return change_detector.detect_crop_change(old_crop, new_crop) is not None
180 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
181 """Build an AdjustImageCropCommand."""
182 from pyPhotoAlbum.models import ImageData
184 if not isinstance(element, ImageData):
185 return None
187 old_crop = start_state.get("crop_info")
188 if old_crop is None:
189 return None
191 new_crop = element.crop_info
192 change_detector = InteractionChangeDetector(threshold=0.001)
193 change_info = change_detector.detect_crop_change(old_crop, new_crop)
195 if change_info is None:
196 return None
198 from pyPhotoAlbum.commands import AdjustImageCropCommand
200 command = AdjustImageCropCommand(element, old_crop, new_crop)
202 self.log_command("Image pan", f"{old_crop} → {new_crop}")
203 return command