1 """

2 Command builders for different interaction types.

3

4 Each builder is responsible for:

5 1. Validating if a command should be created

6 2. Creating the appropriate command object

7 3. Logging the operation

8 """

9

10 from abc import ABC, abstractmethod

11 from typing import Optional, Any

12 from pyPhotoAlbum.models import BaseLayoutElement

13 from .interaction_validators import InteractionChangeDetector

14

15

16 class CommandBuilder(ABC):

17 """Base class for command builders."""

18

19 def __init__(self, change_detector: Optional[InteractionChangeDetector] = None):

20 self.change_detector = change_detector or InteractionChangeDetector()

21

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.

26

27 Args:

28 element: The element being modified

29 start_state: Dict containing the initial state

30 **kwargs: Additional context

31

32 Returns:

33 True if a command should be created

34 """

35 pass

36

37 @abstractmethod

38 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:

39 """

40 Build and return the command object.

41

42 Args:

43 element: The element being modified

44 start_state: Dict containing the initial state

45 **kwargs: Additional context

46

47 Returns:

48 Command object or None

49 """

50 pass

51

52 def log_command(self, command_type: str, details: str):

53 """Log command creation for debugging."""

54 print(f"{command_type} command created: {details}")

55

56

57 class MoveCommandBuilder(CommandBuilder):

58 """Builds MoveElementCommand objects."""

59

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

65

66 new_pos = element.position

67 return self.change_detector.detect_position_change(old_pos, new_pos) is not None

68

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

74

75 new_pos = element.position

76 change_info = self.change_detector.detect_position_change(old_pos, new_pos)

77

78 if change_info is None:

79 return None

80

81 from pyPhotoAlbum.commands import MoveElementCommand

82

83 command = MoveElementCommand(element, old_pos, new_pos)

84

85 self.log_command("Move", f"{old_pos} → {new_pos}")

86 return command

87

88

89 class ResizeCommandBuilder(CommandBuilder):

90 """Builds ResizeElementCommand objects."""

91

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")

96

97 if old_pos is None or old_size is None:

98 return False

99

100 new_pos = element.position

101 new_size = element.size

102

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)

105

106 return pos_change is not None or size_change is not None

107

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")

112

113 if old_pos is None or old_size is None:

114 return None

115

116 new_pos = element.position

117 new_size = element.size

118

119 if not self.can_build(element, start_state):

120 return None

121

122 from pyPhotoAlbum.commands import ResizeElementCommand

123

124 command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)

125

126 self.log_command("Resize", f"{old_size} → {new_size}")

127 return command

128

129

130 class RotateCommandBuilder(CommandBuilder):

131 """Builds RotateElementCommand objects."""

132

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

138

139 new_rotation = element.rotation

140 return self.change_detector.detect_rotation_change(old_rotation, new_rotation) is not None

141

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

147

148 new_rotation = element.rotation

149 change_info = self.change_detector.detect_rotation_change(old_rotation, new_rotation)

150

151 if change_info is None:

152 return None

153

154 from pyPhotoAlbum.commands import RotateElementCommand

155

156 command = RotateElementCommand(element, old_rotation, new_rotation)

157

158 self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")

159 return command

160

161

162 class ImagePanCommandBuilder(CommandBuilder):

163 """Builds AdjustImageCropCommand objects for image panning."""

164

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

168

169 if not isinstance(element, ImageData):

170 return False

171

172 old_crop = start_state.get("crop_info")

173 if old_crop is None:

174 return False

175

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

179

180 def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:

181 """Build an AdjustImageCropCommand."""

182 from pyPhotoAlbum.models import ImageData

183

184 if not isinstance(element, ImageData):

185 return None

186

187 old_crop = start_state.get("crop_info")

188 if old_crop is None:

189 return None

190

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)

194

195 if change_info is None:

196 return None

197

198 from pyPhotoAlbum.commands import AdjustImageCropCommand

199

200 command = AdjustImageCropCommand(element, old_crop, new_crop)

201

202 self.log_command("Image pan", f"{old_crop} → {new_crop}")

203 return command