1 """

2 Style operations mixin for pyPhotoAlbum

3

4 Provides ribbon actions for applying visual styles to images:

5 - Rounded corners

6 - Borders

7 - Drop shadows

8 - (Future) Decorative frames

9 """

10

11 from pyPhotoAlbum.decorators import ribbon_action

12 from pyPhotoAlbum.models import ImageData, ImageStyle

13

14

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