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

1""" 

2Command builders for different interaction types. 

3 

4Each builder is responsible for: 

51. Validating if a command should be created 

62. Creating the appropriate command object 

73. Logging the operation 

8""" 

9 

10from abc import ABC, abstractmethod 

11from typing import Optional, Any 

12from pyPhotoAlbum.models import BaseLayoutElement 

13from .interaction_validators import InteractionChangeDetector 

14 

15 

16class 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 

57class 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 

89class 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 

130class 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 

162class 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