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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Style operations mixin for pyPhotoAlbum
4Provides ribbon actions for applying visual styles to images:
5- Rounded corners
6- Borders
7- Drop shadows
8- (Future) Decorative frames
9"""
11from pyPhotoAlbum.decorators import ribbon_action
12from pyPhotoAlbum.models import ImageData, ImageStyle
15class StyleOperationsMixin:
16 """Mixin providing element styling operations"""
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)]
24 def _apply_style_change(self, style_updater, description: str):
25 """
26 Apply a style change to selected images with undo support.
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
37 # Store old styles for undo
38 old_styles = [(img, img.style.copy()) for img in images]
40 # Create undo command
41 from pyPhotoAlbum.commands import Command
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
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
60 try:
61 glDeleteTextures([img._texture_id])
62 except Exception:
63 pass # GL context might not be available
64 delattr(img, "_texture_id")
66 def execute(self):
67 for img, _ in self.old_styles:
68 self.new_style_updater(img.style)
69 self._invalidate_texture(img)
71 def undo(self):
72 for img, old_style in self.old_styles:
73 img.style = old_style.copy()
74 self._invalidate_texture(img)
76 def redo(self):
77 self.execute()
79 def serialize(self):
80 # Style changes are not serialized (session-only undo)
81 return {"type": "style_change", "description": self.description}
83 @staticmethod
84 def deserialize(data, project):
85 # Style changes cannot be deserialized (session-only)
86 return None
88 cmd = StyleChangeCommand(old_styles, style_updater, description)
89 self.project.history.execute(cmd) # type: ignore[attr-defined]
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]
94 # =========================================================================
95 # Corner Radius
96 # =========================================================================
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
112 from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
114 # Get current radius from first selected image
115 current_radius = images[0].style.corner_radius
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 )
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 )
139 # =========================================================================
140 # Borders
141 # =========================================================================
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
157 from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
159 # Get current border from first selected image
160 current_style = images[0].style
162 dialog = BorderDialog(self, current_style.border_width, current_style.border_color)
163 if dialog.exec():
164 width, color = dialog.get_values()
166 def update_border(style):
167 style.border_width = width
168 style.border_color = color
170 self._apply_style_change(update_border, f"Set border ({width}mm)")
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 )
186 # =========================================================================
187 # Shadows
188 # =========================================================================
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
204 from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
206 # Get current shadow settings from first selected image
207 current_style = images[0].style
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()
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
225 self._apply_style_change(update_shadow, "Configure shadow")
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
241 # Toggle based on first selected image
242 new_state = not images[0].style.shadow_enabled
244 self._apply_style_change(
245 lambda style: setattr(style, "shadow_enabled", new_state),
246 "Enable shadow" if new_state else "Disable shadow",
247 )
249 # =========================================================================
250 # Style Presets
251 # =========================================================================
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"""
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)
272 self._apply_style_change(apply_preset, "Apply Polaroid style")
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"""
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)
292 self._apply_style_change(apply_preset, "Apply rounded style")
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)"""
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)
316 self._apply_style_change(clear_all, "Clear style")
318 # =========================================================================
319 # Decorative Frames
320 # =========================================================================
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
336 from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
338 # Get current frame settings from first selected image
339 current_style = images[0].style
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()
350 def update_frame(style):
351 style.frame_style = frame_name
352 style.frame_color = color
353 style.frame_corners = corners
355 desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame"
356 self._apply_style_change(update_frame, desc)
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"""
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)
373 self._apply_style_change(clear_frame, "Remove frame")