1 """

2 Decorator system for pyPhotoAlbum ribbon UI

3 """

4

5 import copy

6 from functools import wraps

7 from typing import Any, Optional, Callable

8

9

10 class 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

98 def 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

141 class 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

190 def 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

206 class 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

319 def 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

335 class 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

408 def 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)