Coverage for pyPhotoAlbum/decorators.py: 79%
105 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"""
2Decorator system for pyPhotoAlbum ribbon UI
3"""
5import copy
6from functools import wraps
7from typing import Any, Optional, Callable
10class RibbonAction:
11 """
12 Decorator to mark methods as ribbon actions.
14 This decorator stores metadata about UI actions that should appear in the ribbon.
15 The metadata is used to auto-generate the ribbon configuration.
17 Example:
18 @RibbonAction(
19 label="New",
20 tooltip="Create a new project",
21 tab="Home",
22 group="File",
23 icon="new.png",
24 shortcut="Ctrl+N"
25 )
26 def new_project(self):
27 ...
28 """
30 def __init__(
31 self,
32 label: str,
33 tooltip: str,
34 tab: str,
35 group: str,
36 icon: Optional[str] = None,
37 shortcut: Optional[str] = None,
38 requires_page: bool = False,
39 requires_selection: bool = False,
40 min_selection: int = 0,
41 ):
42 """
43 Initialize the ribbon action decorator.
45 Args:
46 label: Button label text
47 tooltip: Tooltip text shown on hover
48 tab: Ribbon tab name (e.g., "Home", "Insert", "Layout")
49 group: Group name within the tab (e.g., "File", "Edit")
50 icon: Optional icon filename or path
51 shortcut: Optional keyboard shortcut (e.g., "Ctrl+N", "Ctrl+Shift+S")
52 requires_page: Whether this action requires an active page
53 requires_selection: Whether this action requires selected elements
54 min_selection: Minimum number of selected elements required
55 """
56 self.label = label
57 self.tooltip = tooltip
58 self.tab = tab
59 self.group = group
60 self.icon = icon
61 self.shortcut = shortcut
62 self.requires_page = requires_page
63 self.requires_selection = requires_selection
64 self.min_selection = min_selection
66 def __call__(self, func: Callable) -> Callable:
67 """
68 Decorate the function with ribbon action metadata.
70 Args:
71 func: The function to decorate
73 Returns:
74 The decorated function with metadata attached
75 """
77 @wraps(func)
78 def wrapper(*args: Any, **kwargs: Any) -> Any:
79 return func(*args, **kwargs)
81 # Store metadata on wrapper function
82 wrapper._ribbon_action = { # type: ignore[attr-defined]
83 "label": self.label,
84 "tooltip": self.tooltip,
85 "tab": self.tab,
86 "group": self.group,
87 "icon": self.icon,
88 "shortcut": self.shortcut,
89 "action": func.__name__,
90 "requires_page": self.requires_page,
91 "requires_selection": self.requires_selection,
92 "min_selection": self.min_selection,
93 }
95 return wrapper
98def ribbon_action(
99 label: str,
100 tooltip: str,
101 tab: str,
102 group: str,
103 icon: Optional[str] = None,
104 shortcut: Optional[str] = None,
105 requires_page: bool = False,
106 requires_selection: bool = False,
107 min_selection: int = 0,
108) -> Callable:
109 """
110 Convenience function for the RibbonAction decorator.
112 This provides a lowercase function-style interface to the decorator.
114 Args:
115 label: Button label text
116 tooltip: Tooltip text shown on hover
117 tab: Ribbon tab name
118 group: Group name within the tab
119 icon: Optional icon filename or path
120 shortcut: Optional keyboard shortcut
121 requires_page: Whether this action requires an active page
122 requires_selection: Whether this action requires selected elements
123 min_selection: Minimum number of selected elements required
125 Returns:
126 RibbonAction decorator instance
127 """
128 return RibbonAction(
129 label=label,
130 tooltip=tooltip,
131 tab=tab,
132 group=group,
133 icon=icon,
134 shortcut=shortcut,
135 requires_page=requires_page,
136 requires_selection=requires_selection,
137 min_selection=min_selection,
138 )
141class NumericalInput:
142 """
143 Decorator to mark methods that require numerical width/height inputs.
145 This decorator stores metadata about numerical input fields that should
146 be presented in dialogs for methods that work with page dimensions.
148 Example:
149 @numerical_input(
150 fields=[
151 ('width', 'Width', 'mm', 10, 1000),
152 ('height', 'Height', 'mm', 10, 1000)
153 ]
154 )
155 def set_page_size(self, width, height):
156 ...
157 """
159 def __init__(self, fields: list):
160 """
161 Initialize the numerical input decorator.
163 Args:
164 fields: List of tuples, each containing:
165 (param_name, label, unit, min_value, max_value)
166 """
167 self.fields = fields
169 def __call__(self, func: Callable) -> Callable:
170 """
171 Decorate the function with numerical input metadata.
173 Args:
174 func: The function to decorate
176 Returns:
177 The decorated function with metadata attached
178 """
180 @wraps(func)
181 def wrapper(*args: Any, **kwargs: Any) -> Any:
182 return func(*args, **kwargs)
184 # Store metadata on wrapper function
185 wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
187 return wrapper
190def numerical_input(fields: list) -> Callable:
191 """
192 Convenience function for the NumericalInput decorator.
194 This provides a lowercase function-style interface to the decorator.
196 Args:
197 fields: List of tuples, each containing:
198 (param_name, label, unit, min_value, max_value)
200 Returns:
201 NumericalInput decorator instance
202 """
203 return NumericalInput(fields=fields)
206class UndoableOperation:
207 """
208 Decorator to automatically create undo/redo commands for operations.
210 This decorator captures state before and after an operation, then creates
211 a StateChangeCommand for undo/redo functionality.
213 Example:
214 @undoable_operation(capture='page_elements')
215 def apply_template(self):
216 # Just implement the operation
217 self.template_manager.apply_template(...)
218 # Decorator handles undo/redo automatically
219 """
221 def __init__(self, capture: str = "page_elements", description: Optional[str] = None):
222 """
223 Initialize the undoable operation decorator.
225 Args:
226 capture: What to capture for undo/redo:
227 - 'page_elements': Capture elements of current page
228 - 'custom': Operation provides its own capture logic
229 description: Human-readable description (defaults to function name)
230 """
231 self.capture = capture
232 self.description = description
234 def __call__(self, func: Callable) -> Callable:
235 """
236 Decorate the function with automatic undo/redo.
238 Args:
239 func: The function to decorate
241 Returns:
242 The decorated function
243 """
245 @wraps(func)
246 def wrapper(self_instance, *args, **kwargs):
247 # Get description
248 description = self.description or func.__name__.replace("_", " ").title()
250 # Capture before state
251 before_state = self._capture_state(self_instance, self.capture)
253 # Execute the operation
254 result = func(self_instance, *args, **kwargs)
256 # Capture after state
257 after_state = self._capture_state(self_instance, self.capture)
259 # Create restore function
260 def restore_state(state):
261 self._restore_state(self_instance, self.capture, state)
262 # Update view after restoring
263 if hasattr(self_instance, "update_view"):
264 self_instance.update_view()
266 # Create and execute command
267 from pyPhotoAlbum.commands import StateChangeCommand
269 cmd = StateChangeCommand(description, restore_state, before_state, after_state)
271 if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
272 self_instance.project.history.execute(cmd)
273 print(f"Undoable operation '{description}' executed")
275 return result
277 return wrapper
279 def _capture_state(self, instance, capture_type: str):
280 """Capture current state based on capture type"""
281 if capture_type == "page_elements":
282 # Capture elements from current page
283 current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
284 if current_page:
285 # Deep copy elements
286 return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
287 return []
289 return None
291 def _restore_state(self, instance, capture_type: str, state):
292 """Restore state based on capture type"""
293 if capture_type == "page_elements":
294 # Restore elements to current page
295 current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
296 if current_page and state is not None:
297 # Clear existing elements
298 current_page.layout.elements.clear()
300 # Restore elements from serialized state
301 from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
303 for elem_data in state:
304 elem_type = elem_data.get("type")
305 elem: BaseLayoutElement
306 if elem_type == "image":
307 elem = ImageData()
308 elif elem_type == "placeholder":
309 elem = PlaceholderData()
310 elif elem_type == "textbox":
311 elem = TextBoxData()
312 else:
313 continue
315 elem.deserialize(elem_data)
316 current_page.layout.add_element(elem)
319def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable:
320 """
321 Convenience function for the UndoableOperation decorator.
323 This provides a lowercase function-style interface to the decorator.
325 Args:
326 capture: What to capture for undo/redo
327 description: Human-readable description of the operation
329 Returns:
330 UndoableOperation decorator instance
331 """
332 return UndoableOperation(capture=capture, description=description)
335class DialogAction:
336 """
337 Decorator to mark methods that should open a dialog.
339 This decorator automatically handles dialog creation and result processing,
340 separating UI presentation from business logic.
342 Example:
343 @dialog_action(dialog_class=PageSetupDialog)
344 def page_setup(self, values):
345 # Just implement the business logic
346 # Dialog presentation is handled automatically
347 self.apply_page_setup(values)
348 """
350 def __init__(self, dialog_class: type, requires_pages: bool = True):
351 """
352 Initialize the dialog action decorator.
354 Args:
355 dialog_class: The dialog class to instantiate
356 requires_pages: Whether this action requires pages to exist
357 """
358 self.dialog_class = dialog_class
359 self.requires_pages = requires_pages
361 def __call__(self, func: Callable) -> Callable:
362 """
363 Decorate the function with automatic dialog handling.
365 Args:
366 func: The function to decorate (receives dialog values)
368 Returns:
369 The decorated function
370 """
372 @wraps(func)
373 def wrapper(self_instance, *args, **kwargs):
374 # Check preconditions
375 if self.requires_pages and not self_instance.project.pages:
376 return
378 # Get initial page index if available
379 initial_page_index = 0
380 if hasattr(self_instance, "_get_most_visible_page_index"):
381 initial_page_index = self_instance._get_most_visible_page_index()
383 # Create and show dialog
384 from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
386 # Create dialog
387 dialog = self.dialog_class(
388 parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
389 )
391 # Show dialog and get result
392 from PyQt6.QtWidgets import QDialog
394 if dialog.exec() == QDialog.DialogCode.Accepted:
395 # Get values from dialog
396 if hasattr(dialog, "get_values"):
397 values = dialog.get_values()
398 # Call the decorated function with values
399 return func(self_instance, values, *args, **kwargs)
400 else:
401 return func(self_instance, *args, **kwargs)
403 return None
405 return wrapper
408def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
409 """
410 Convenience function for the DialogAction decorator.
412 This provides a lowercase function-style interface to the decorator.
414 Args:
415 dialog_class: The dialog class to instantiate
416 requires_pages: Whether this action requires pages to exist
418 Returns:
419 DialogAction decorator instance
420 """
421 return DialogAction(dialog_class=dialog_class, requires_pages=requires_pages)