Coverage for pyPhotoAlbum/mixins/operations/style_ops.py: 16%

166 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Style operations mixin for pyPhotoAlbum 

3 

4Provides ribbon actions for applying visual styles to images: 

5- Rounded corners 

6- Borders 

7- Drop shadows 

8- (Future) Decorative frames 

9""" 

10 

11from pyPhotoAlbum.decorators import ribbon_action 

12from pyPhotoAlbum.models import ImageData, ImageStyle 

13 

14 

15class StyleOperationsMixin: 

16 """Mixin providing element styling operations""" 

17 

18 def _get_selected_images(self): 

19 """Get list of selected ImageData elements""" 

20 if not self.gl_widget.selected_elements: 

21 return [] 

22 return [e for e in self.gl_widget.selected_elements if isinstance(e, ImageData)] 

23 

24 def _apply_style_change(self, style_updater, description: str): 

25 """ 

26 Apply a style change to selected images with undo support. 

27 

28 Args: 

29 style_updater: Function that takes an ImageStyle and modifies it 

30 description: Description for undo history 

31 """ 

32 images = self._get_selected_images() 

33 if not images: 

34 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

35 return 

36 

37 # Store old styles for undo 

38 old_styles = [(img, img.style.copy()) for img in images] 

39 

40 # Create undo command 

41 from pyPhotoAlbum.commands import Command 

42 

43 class StyleChangeCommand(Command): 

44 def __init__(self, old_styles, new_style_updater, desc): 

45 self.old_styles = old_styles 

46 self.new_style_updater = new_style_updater 

47 self.description = desc 

48 

49 def _invalidate_texture(self, img): 

50 """Invalidate the image texture so it will be regenerated.""" 

51 # Clear the style hash to force regeneration check 

52 if hasattr(img, "_texture_style_hash"): 

53 delattr(img, "_texture_style_hash") 

54 # Clear async load state so it will reload 

55 img._async_load_requested = False 

56 # Delete texture if it exists (will be recreated on next render) 

57 if hasattr(img, "_texture_id") and img._texture_id: 

58 from pyPhotoAlbum.gl_imports import glDeleteTextures 

59 

60 try: 

61 glDeleteTextures([img._texture_id]) 

62 except Exception: 

63 pass # GL context might not be available 

64 delattr(img, "_texture_id") 

65 

66 def execute(self): 

67 for img, _ in self.old_styles: 

68 self.new_style_updater(img.style) 

69 self._invalidate_texture(img) 

70 

71 def undo(self): 

72 for img, old_style in self.old_styles: 

73 img.style = old_style.copy() 

74 self._invalidate_texture(img) 

75 

76 def redo(self): 

77 self.execute() 

78 

79 def serialize(self): 

80 # Style changes are not serialized (session-only undo) 

81 return {"type": "style_change", "description": self.description} 

82 

83 @staticmethod 

84 def deserialize(data, project): 

85 # Style changes cannot be deserialized (session-only) 

86 return None 

87 

88 cmd = StyleChangeCommand(old_styles, style_updater, description) 

89 self.project.history.execute(cmd) # type: ignore[attr-defined] 

90 

91 self.update_view() # type: ignore[attr-defined] 

92 self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined] 

93 

94 # ========================================================================= 

95 # Corner Radius 

96 # ========================================================================= 

97 

98 @ribbon_action( 

99 label="Round Corners", 

100 tooltip="Set corner radius for selected images", 

101 tab="Style", 

102 group="Corners", 

103 requires_selection=True, 

104 ) 

105 def show_corner_radius_dialog(self): 

106 """Show dialog to set corner radius""" 

107 images = self._get_selected_images() 

108 if not images: 

109 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

110 return 

111 

112 from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog 

113 

114 # Get current radius from first selected image 

115 current_radius = images[0].style.corner_radius 

116 

117 dialog = CornerRadiusDialog(self, current_radius) 

118 if dialog.exec(): 

119 new_radius = dialog.get_value() 

120 self._apply_style_change( 

121 lambda style: setattr(style, "corner_radius", new_radius), 

122 f"Set corner radius to {new_radius}%", 

123 ) 

124 

125 @ribbon_action( 

126 label="No Corners", 

127 tooltip="Remove rounded corners from selected images", 

128 tab="Style", 

129 group="Corners", 

130 requires_selection=True, 

131 ) 

132 def remove_corner_radius(self): 

133 """Remove corner radius (set to 0)""" 

134 self._apply_style_change( 

135 lambda style: setattr(style, "corner_radius", 0.0), 

136 "Remove corner radius", 

137 ) 

138 

139 # ========================================================================= 

140 # Borders 

141 # ========================================================================= 

142 

143 @ribbon_action( 

144 label="Border...", 

145 tooltip="Set border for selected images", 

146 tab="Style", 

147 group="Border", 

148 requires_selection=True, 

149 ) 

150 def show_border_dialog(self): 

151 """Show dialog to configure border""" 

152 images = self._get_selected_images() 

153 if not images: 

154 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

155 return 

156 

157 from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog 

158 

159 # Get current border from first selected image 

160 current_style = images[0].style 

161 

162 dialog = BorderDialog(self, current_style.border_width, current_style.border_color) 

163 if dialog.exec(): 

164 width, color = dialog.get_values() 

165 

166 def update_border(style): 

167 style.border_width = width 

168 style.border_color = color 

169 

170 self._apply_style_change(update_border, f"Set border ({width}mm)") 

171 

172 @ribbon_action( 

173 label="No Border", 

174 tooltip="Remove border from selected images", 

175 tab="Style", 

176 group="Border", 

177 requires_selection=True, 

178 ) 

179 def remove_border(self): 

180 """Remove border (set width to 0)""" 

181 self._apply_style_change( 

182 lambda style: setattr(style, "border_width", 0.0), 

183 "Remove border", 

184 ) 

185 

186 # ========================================================================= 

187 # Shadows 

188 # ========================================================================= 

189 

190 @ribbon_action( 

191 label="Shadow...", 

192 tooltip="Configure drop shadow for selected images", 

193 tab="Style", 

194 group="Effects", 

195 requires_selection=True, 

196 ) 

197 def show_shadow_dialog(self): 

198 """Show dialog to configure drop shadow""" 

199 images = self._get_selected_images() 

200 if not images: 

201 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

202 return 

203 

204 from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog 

205 

206 # Get current shadow settings from first selected image 

207 current_style = images[0].style 

208 

209 dialog = ShadowDialog( 

210 self, 

211 current_style.shadow_enabled, 

212 current_style.shadow_offset, 

213 current_style.shadow_blur, 

214 current_style.shadow_color, 

215 ) 

216 if dialog.exec(): 

217 enabled, offset, blur, color = dialog.get_values() 

218 

219 def update_shadow(style): 

220 style.shadow_enabled = enabled 

221 style.shadow_offset = offset 

222 style.shadow_blur = blur 

223 style.shadow_color = color 

224 

225 self._apply_style_change(update_shadow, "Configure shadow") 

226 

227 @ribbon_action( 

228 label="Toggle Shadow", 

229 tooltip="Toggle drop shadow on/off for selected images", 

230 tab="Style", 

231 group="Effects", 

232 requires_selection=True, 

233 ) 

234 def toggle_shadow(self): 

235 """Toggle shadow enabled/disabled""" 

236 images = self._get_selected_images() 

237 if not images: 

238 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

239 return 

240 

241 # Toggle based on first selected image 

242 new_state = not images[0].style.shadow_enabled 

243 

244 self._apply_style_change( 

245 lambda style: setattr(style, "shadow_enabled", new_state), 

246 "Enable shadow" if new_state else "Disable shadow", 

247 ) 

248 

249 # ========================================================================= 

250 # Style Presets 

251 # ========================================================================= 

252 

253 @ribbon_action( 

254 label="Polaroid", 

255 tooltip="Apply Polaroid-style frame (white border, shadow)", 

256 tab="Style", 

257 group="Presets", 

258 requires_selection=True, 

259 ) 

260 def apply_polaroid_style(self): 

261 """Apply Polaroid-style preset""" 

262 

263 def apply_preset(style): 

264 style.corner_radius = 0.0 

265 style.border_width = 3.0 # 3mm white border 

266 style.border_color = (255, 255, 255) 

267 style.shadow_enabled = True 

268 style.shadow_offset = (2.0, 2.0) 

269 style.shadow_blur = 4.0 

270 style.shadow_color = (0, 0, 0, 100) 

271 

272 self._apply_style_change(apply_preset, "Apply Polaroid style") 

273 

274 @ribbon_action( 

275 label="Rounded", 

276 tooltip="Apply rounded photo style", 

277 tab="Style", 

278 group="Presets", 

279 requires_selection=True, 

280 ) 

281 def apply_rounded_style(self): 

282 """Apply rounded corners preset""" 

283 

284 def apply_preset(style): 

285 style.corner_radius = 10.0 # 10% rounded 

286 style.border_width = 0.0 

287 style.shadow_enabled = True 

288 style.shadow_offset = (1.5, 1.5) 

289 style.shadow_blur = 3.0 

290 style.shadow_color = (0, 0, 0, 80) 

291 

292 self._apply_style_change(apply_preset, "Apply rounded style") 

293 

294 @ribbon_action( 

295 label="Clear Style", 

296 tooltip="Remove all styling from selected images", 

297 tab="Style", 

298 group="Presets", 

299 requires_selection=True, 

300 ) 

301 def clear_style(self): 

302 """Remove all styling (reset to defaults)""" 

303 

304 def clear_all(style): 

305 style.corner_radius = 0.0 

306 style.border_width = 0.0 

307 style.border_color = (0, 0, 0) 

308 style.shadow_enabled = False 

309 style.shadow_offset = (2.0, 2.0) 

310 style.shadow_blur = 3.0 

311 style.shadow_color = (0, 0, 0, 128) 

312 style.frame_style = None 

313 style.frame_color = (0, 0, 0) 

314 style.frame_corners = (True, True, True, True) 

315 

316 self._apply_style_change(clear_all, "Clear style") 

317 

318 # ========================================================================= 

319 # Decorative Frames 

320 # ========================================================================= 

321 

322 @ribbon_action( 

323 label="Frame...", 

324 tooltip="Add decorative frame to selected images", 

325 tab="Style", 

326 group="Frame", 

327 requires_selection=True, 

328 ) 

329 def show_frame_picker(self): 

330 """Show dialog to select decorative frame""" 

331 images = self._get_selected_images() 

332 if not images: 

333 self.show_status("No images selected", 2000) # type: ignore[attr-defined] 

334 return 

335 

336 from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog 

337 

338 # Get current frame settings from first selected image 

339 current_style = images[0].style 

340 

341 dialog = FramePickerDialog( 

342 self, 

343 current_frame=current_style.frame_style, 

344 current_color=current_style.frame_color, 

345 current_corners=current_style.frame_corners, 

346 ) 

347 if dialog.exec(): 

348 frame_name, color, corners = dialog.get_values() 

349 

350 def update_frame(style): 

351 style.frame_style = frame_name 

352 style.frame_color = color 

353 style.frame_corners = corners 

354 

355 desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame" 

356 self._apply_style_change(update_frame, desc) 

357 

358 @ribbon_action( 

359 label="Remove Frame", 

360 tooltip="Remove decorative frame from selected images", 

361 tab="Style", 

362 group="Frame", 

363 requires_selection=True, 

364 ) 

365 def remove_frame(self): 

366 """Remove decorative frame""" 

367 

368 def clear_frame(style): 

369 style.frame_style = None 

370 style.frame_color = (0, 0, 0) 

371 style.frame_corners = (True, True, True, True) 

372 

373 self._apply_style_change(clear_frame, "Remove frame")