Coverage for pyPhotoAlbum/decorators.py: 79%

105 statements  

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

1""" 

2Decorator system for pyPhotoAlbum ribbon UI 

3""" 

4 

5import copy 

6from functools import wraps 

7from typing import Any, Optional, Callable 

8 

9 

10class RibbonAction: 

11 """ 

12 Decorator to mark methods as ribbon actions. 

13 

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. 

16 

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 """ 

29 

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. 

44 

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 

65 

66 def __call__(self, func: Callable) -> Callable: 

67 """ 

68 Decorate the function with ribbon action metadata. 

69 

70 Args: 

71 func: The function to decorate 

72 

73 Returns: 

74 The decorated function with metadata attached 

75 """ 

76 

77 @wraps(func) 

78 def wrapper(*args: Any, **kwargs: Any) -> Any: 

79 return func(*args, **kwargs) 

80 

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 } 

94 

95 return wrapper 

96 

97 

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. 

111 

112 This provides a lowercase function-style interface to the decorator. 

113 

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 

124 

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 ) 

139 

140 

141class NumericalInput: 

142 """ 

143 Decorator to mark methods that require numerical width/height inputs. 

144 

145 This decorator stores metadata about numerical input fields that should 

146 be presented in dialogs for methods that work with page dimensions. 

147 

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 """ 

158 

159 def __init__(self, fields: list): 

160 """ 

161 Initialize the numerical input decorator. 

162 

163 Args: 

164 fields: List of tuples, each containing: 

165 (param_name, label, unit, min_value, max_value) 

166 """ 

167 self.fields = fields 

168 

169 def __call__(self, func: Callable) -> Callable: 

170 """ 

171 Decorate the function with numerical input metadata. 

172 

173 Args: 

174 func: The function to decorate 

175 

176 Returns: 

177 The decorated function with metadata attached 

178 """ 

179 

180 @wraps(func) 

181 def wrapper(*args: Any, **kwargs: Any) -> Any: 

182 return func(*args, **kwargs) 

183 

184 # Store metadata on wrapper function 

185 wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined] 

186 

187 return wrapper 

188 

189 

190def numerical_input(fields: list) -> Callable: 

191 """ 

192 Convenience function for the NumericalInput decorator. 

193 

194 This provides a lowercase function-style interface to the decorator. 

195 

196 Args: 

197 fields: List of tuples, each containing: 

198 (param_name, label, unit, min_value, max_value) 

199 

200 Returns: 

201 NumericalInput decorator instance 

202 """ 

203 return NumericalInput(fields=fields) 

204 

205 

206class UndoableOperation: 

207 """ 

208 Decorator to automatically create undo/redo commands for operations. 

209 

210 This decorator captures state before and after an operation, then creates 

211 a StateChangeCommand for undo/redo functionality. 

212 

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 """ 

220 

221 def __init__(self, capture: str = "page_elements", description: Optional[str] = None): 

222 """ 

223 Initialize the undoable operation decorator. 

224 

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 

233 

234 def __call__(self, func: Callable) -> Callable: 

235 """ 

236 Decorate the function with automatic undo/redo. 

237 

238 Args: 

239 func: The function to decorate 

240 

241 Returns: 

242 The decorated function 

243 """ 

244 

245 @wraps(func) 

246 def wrapper(self_instance, *args, **kwargs): 

247 # Get description 

248 description = self.description or func.__name__.replace("_", " ").title() 

249 

250 # Capture before state 

251 before_state = self._capture_state(self_instance, self.capture) 

252 

253 # Execute the operation 

254 result = func(self_instance, *args, **kwargs) 

255 

256 # Capture after state 

257 after_state = self._capture_state(self_instance, self.capture) 

258 

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() 

265 

266 # Create and execute command 

267 from pyPhotoAlbum.commands import StateChangeCommand 

268 

269 cmd = StateChangeCommand(description, restore_state, before_state, after_state) 

270 

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

274 

275 return result 

276 

277 return wrapper 

278 

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 [] 

288 

289 return None 

290 

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() 

299 

300 # Restore elements from serialized state 

301 from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData 

302 

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 

314 

315 elem.deserialize(elem_data) 

316 current_page.layout.add_element(elem) 

317 

318 

319def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable: 

320 """ 

321 Convenience function for the UndoableOperation decorator. 

322 

323 This provides a lowercase function-style interface to the decorator. 

324 

325 Args: 

326 capture: What to capture for undo/redo 

327 description: Human-readable description of the operation 

328 

329 Returns: 

330 UndoableOperation decorator instance 

331 """ 

332 return UndoableOperation(capture=capture, description=description) 

333 

334 

335class DialogAction: 

336 """ 

337 Decorator to mark methods that should open a dialog. 

338 

339 This decorator automatically handles dialog creation and result processing, 

340 separating UI presentation from business logic. 

341 

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 """ 

349 

350 def __init__(self, dialog_class: type, requires_pages: bool = True): 

351 """ 

352 Initialize the dialog action decorator. 

353 

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 

360 

361 def __call__(self, func: Callable) -> Callable: 

362 """ 

363 Decorate the function with automatic dialog handling. 

364 

365 Args: 

366 func: The function to decorate (receives dialog values) 

367 

368 Returns: 

369 The decorated function 

370 """ 

371 

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 

377 

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() 

382 

383 # Create and show dialog 

384 from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin 

385 

386 # Create dialog 

387 dialog = self.dialog_class( 

388 parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs 

389 ) 

390 

391 # Show dialog and get result 

392 from PyQt6.QtWidgets import QDialog 

393 

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) 

402 

403 return None 

404 

405 return wrapper 

406 

407 

408def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable: 

409 """ 

410 Convenience function for the DialogAction decorator. 

411 

412 This provides a lowercase function-style interface to the decorator. 

413 

414 Args: 

415 dialog_class: The dialog class to instantiate 

416 requires_pages: Whether this action requires pages to exist 

417 

418 Returns: 

419 DialogAction decorator instance 

420 """ 

421 return DialogAction(dialog_class=dialog_class, requires_pages=requires_pages)