1 """

2 Command pattern implementation for undo/redo functionality

3 """

4

5 import os

6 from abc import ABC, abstractmethod

7 from typing import Dict, Any, List, Optional

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

9

10

11 def _normalize_asset_path(image_path: str, asset_manager) -> str:

12 """

13 Convert absolute path to relative for asset manager.

14

15 Args:

16 image_path: Image path (absolute or relative)

17 asset_manager: AssetManager instance

18

19 Returns:

20 Relative path suitable for asset manager

21 """

22 if not asset_manager or not image_path:

23 return image_path

24

25 if os.path.isabs(image_path):

26 return os.path.relpath(image_path, asset_manager.project_folder)

27 return image_path

28

29

30 def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:

31 """

32 Deserialize element data into the appropriate element type.

33

34 Args:

35 elem_data: Dictionary containing serialized element data with 'type' key

36

37 Returns:

38 Deserialized element instance (ImageData, PlaceholderData, or TextBoxData)

39

40 Raises:

41 ValueError: If element type is unknown

42 """

43 elem_type = elem_data.get("type")

44

45 element: BaseLayoutElement

46 if elem_type == "image":

47 element = ImageData()

48 elif elem_type == "placeholder":

49 element = PlaceholderData()

50 elif elem_type == "textbox":

51 element = TextBoxData()

52 else:

53 raise ValueError(f"Unknown element type: {elem_type}")

54

55 element.deserialize(elem_data)

56 return element

57

58

59 class Command(ABC):

60 """Abstract base class for all commands"""

61

62 @abstractmethod

63 def execute(self):

64 """Execute the command"""

65 pass

66

67 @abstractmethod

68 def undo(self):

69 """Undo the command"""

70 pass

71

72 @abstractmethod

73 def redo(self):

74 """Redo the command (default implementation calls execute)"""

75 self.execute()

76

77 @abstractmethod

78 def serialize(self) -> Dict[str, Any]:

79 """Serialize command to dictionary for saving"""

80 pass

81

82 @staticmethod

83 @abstractmethod

84 def deserialize(data: Dict[str, Any], project) -> "Command":

85 """Deserialize command from dictionary"""

86 pass

87

88

89 class AddElementCommand(Command):

90 """Command for adding an element to a page"""

91

92 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):

93 self.page_layout = page_layout

94 self.element = element

95 self.executed = False

96 self.asset_manager = asset_manager

97

98 # Acquire reference to asset when command is created

99 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:

100 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)

101 self.asset_manager.acquire_reference(rel_path)

102

103 def execute(self):

104 """Add the element to the page"""

105 if not self.executed:

106 self.page_layout.add_element(self.element)

107 self.executed = True

108

109 def undo(self):

110 """Remove the element from the page"""

111 if self.executed:

112 self.page_layout.remove_element(self.element)

113 self.executed = False

114

115 def redo(self):

116 """Re-add the element"""

117 self.execute()

118

119 def serialize(self) -> Dict[str, Any]:

120 """Serialize to dictionary"""

121 return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}

122

123 @staticmethod

124 def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":

125 """Deserialize from dictionary"""

126 element = _deserialize_element(data["element"])

127 # Note: page_layout will be handled by the CommandHistory deserializer

128 cmd = AddElementCommand(None, element)

129 cmd.executed = data.get("executed", False)

130 return cmd

131

132

133 class DeleteElementCommand(Command):

134 """Command for deleting an element from a page"""

135

136 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):

137 self.page_layout = page_layout

138 self.element = element

139 self.executed = False

140 self.asset_manager = asset_manager

141

142 # Acquire reference to asset to keep it alive while in undo history

143 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:

144 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)

145 self.asset_manager.acquire_reference(rel_path)

146

147 def execute(self):

148 """Remove the element from the page"""

149 if not self.executed:

150 self.page_layout.remove_element(self.element)

151 self.executed = True

152

153 def undo(self):

154 """Re-add the element to the page"""

155 if self.executed:

156 self.page_layout.add_element(self.element)

157 self.executed = False

158

159 def redo(self):

160 """Re-remove the element"""

161 self.execute()

162

163 def serialize(self) -> Dict[str, Any]:

164 """Serialize to dictionary"""

165 return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}

166

167 @staticmethod

168 def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":

169 """Deserialize from dictionary"""

170 element = _deserialize_element(data["element"])

171 cmd = DeleteElementCommand(None, element)

172 cmd.executed = data.get("executed", False)

173 return cmd

174

175

176 class MoveElementCommand(Command):

177 """Command for moving an element"""

178

179 def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):

180 self.element = element

181 self.old_position = old_position

182 self.new_position = new_position

183

184 def execute(self):

185 """Move element to new position"""

186 self.element.position = self.new_position

187

188 def undo(self):

189 """Move element back to old position"""

190 self.element.position = self.old_position

191

192 def redo(self):

193 """Move element to new position again"""

194 self.execute()

195

196 def serialize(self) -> Dict[str, Any]:

197 """Serialize to dictionary"""

198 return {

199 "type": "move_element",

200 "element": self.element.serialize(),

201 "old_position": self.old_position,

202 "new_position": self.new_position,

203 }

204

205 @staticmethod

206 def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":

207 """Deserialize from dictionary"""

208 element = _deserialize_element(data["element"])

209 return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))

210

211

212 class ResizeElementCommand(Command):

213 """Command for resizing an element"""

214

215 def __init__(

216 self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple

217 ):

218 self.element = element

219 self.old_position = old_position

220 self.old_size = old_size

221 self.new_position = new_position

222 self.new_size = new_size

223

224 def execute(self):

225 """Resize element to new size"""

226 self.element.position = self.new_position

227 self.element.size = self.new_size

228

229 def undo(self):

230 """Resize element back to old size"""

231 self.element.position = self.old_position

232 self.element.size = self.old_size

233

234 def redo(self):

235 """Resize element to new size again"""

236 self.execute()

237

238 def serialize(self) -> Dict[str, Any]:

239 """Serialize to dictionary"""

240 return {

241 "type": "resize_element",

242 "element": self.element.serialize(),

243 "old_position": self.old_position,

244 "old_size": self.old_size,

245 "new_position": self.new_position,

246 "new_size": self.new_size,

247 }

248

249 @staticmethod

250 def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":

251 """Deserialize from dictionary"""

252 element = _deserialize_element(data["element"])

253 return ResizeElementCommand(

254 element,

255 tuple(data["old_position"]),

256 tuple(data["old_size"]),

257 tuple(data["new_position"]),

258 tuple(data["new_size"]),

259 )

260

261

262 class RotateElementCommand(Command):

263 """Command for rotating an element"""

264

265 def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):

266 self.element = element

267 self.old_rotation = old_rotation

268 self.new_rotation = new_rotation

269

270 # Store old position, size, and PIL rotation state

271 self.old_position = element.position

272 self.old_size = element.size

273

274 # For ImageData, store the old PIL rotation state

275 if hasattr(element, "pil_rotation_90"):

276 self.old_pil_rotation = element.pil_rotation_90

277 else:

278 self.old_pil_rotation = None

279

280 def execute(self):

281 """Rotate element by physically rotating the PIL image data"""

282 from pyPhotoAlbum.models import ImageData

283

284 # Calculate rotation delta

285 delta = (self.new_rotation - self.old_rotation) % 360

286

287 # For ImageData, rotate the actual PIL image

288 if isinstance(self.element, ImageData):

289 # Update PIL rotation counter

290 if delta == 90:

291 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4

292 elif delta == 270:

293 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4

294 elif delta == 180:

295 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4

296

297 # For 90° or 270° rotations, swap dimensions

298 if delta == 90 or delta == 270:

299 w, h = self.element.size

300 x, y = self.element.position

301

302 # Swap dimensions

303 self.element.size = (h, w)

304

305 # Adjust position to keep center in same place

306 center_x = x + w / 2

307 center_y = y + h / 2

308 self.element.position = (center_x - h / 2, center_y - w / 2)

309

310 # Clear the texture so it will be reloaded with the new rotation

311 if hasattr(self.element, "_texture_id"):

312 del self.element._texture_id

313 if hasattr(self.element, "_async_load_requested"):

314 self.element._async_load_requested = False

315

316 # Keep visual rotation at 0

317 self.element.rotation = 0

318 else:

319 # For non-image elements, use old visual rotation

320 if delta == 90 or delta == 270:

321 w, h = self.element.size

322 x, y = self.element.position

323 self.element.size = (h, w)

324 center_x = x + w / 2

325 center_y = y + h / 2

326 self.element.position = (center_x - h / 2, center_y - w / 2)

327 self.element.rotation = 0

328 else:

329 self.element.rotation = self.new_rotation

330

331 def undo(self):

332 """Restore element back to old state"""

333 from pyPhotoAlbum.models import ImageData

334

335 # Restore original rotation, position, and size

336 self.element.rotation = self.old_rotation

337 self.element.position = self.old_position

338 self.element.size = self.old_size

339

340 # For ImageData, restore PIL rotation and clear texture

341 if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:

342 self.element.pil_rotation_90 = self.old_pil_rotation

343 if hasattr(self.element, "_texture_id"):

344 self.element._texture_id = None

345 self.element._async_load_requested = False

346

347 def redo(self):

348 """Rotate element to new angle again"""

349 self.execute()

350

351 def serialize(self) -> Dict[str, Any]:

352 """Serialize to dictionary"""

353 return {

354 "type": "rotate_element",

355 "element": self.element.serialize(),

356 "old_rotation": self.old_rotation,

357 "new_rotation": self.new_rotation,

358 }

359

360 @staticmethod

361 def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":

362 """Deserialize from dictionary"""

363 element = _deserialize_element(data["element"])

364 return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])

365

366

367 class AdjustImageCropCommand(Command):

368 """Command for adjusting image crop/pan within frame"""

369

370 def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):

371 self.element = element

372 self.old_crop_info = old_crop_info

373 self.new_crop_info = new_crop_info

374

375 def execute(self):

376 """Apply new crop info"""

377 self.element.crop_info = self.new_crop_info

378

379 def undo(self):

380 """Restore old crop info"""

381 self.element.crop_info = self.old_crop_info

382

383 def redo(self):

384 """Apply new crop info again"""

385 self.execute()

386

387 def serialize(self) -> Dict[str, Any]:

388 """Serialize to dictionary"""

389 return {

390 "type": "adjust_image_crop",

391 "element": self.element.serialize(),

392 "old_crop_info": self.old_crop_info,

393 "new_crop_info": self.new_crop_info,

394 }

395

396 @staticmethod

397 def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":

398 """Deserialize from dictionary"""

399 elem_data = data["element"]

400 element = ImageData()

401 element.deserialize(elem_data)

402

403 return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))

404

405

406 class AlignElementsCommand(Command):

407 """Command for aligning multiple elements"""

408

409 def __init__(self, changes: List[tuple]):

410 """

411 Args:

412 changes: List of (element, old_position) tuples

413 """

414 self.changes = changes

415

416 def execute(self):

417 """Positions have already been set by AlignmentManager"""

418 pass

419

420 def undo(self):

421 """Restore old positions"""

422 for element, old_position in self.changes:

423 element.position = old_position

424

425 def redo(self):

426 """Re-apply alignment (positions are stored in current state)"""

427 # Store current positions and restore them

428 new_positions = [(elem, elem.position) for elem, _ in self.changes]

429 for element, old_position in self.changes:

430 element.position = old_position

431 # Then re-apply new positions

432 for element, new_position in new_positions:

433 element.position = new_position

434

435 def serialize(self) -> Dict[str, Any]:

436 """Serialize to dictionary"""

437 return {

438 "type": "align_elements",

439 "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],

440 }

441

442 @staticmethod

443 def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":

444 """Deserialize from dictionary"""

445 changes = []

446 for change_data in data.get("changes", []):

447 try:

448 element = _deserialize_element(change_data["element"])

449 old_position = tuple(change_data["old_position"])

450 changes.append((element, old_position))

451 except ValueError:

452 continue

453 return AlignElementsCommand(changes)

454

455

456 class ResizeElementsCommand(Command):

457 """Command for resizing multiple elements"""

458

459 def __init__(self, changes: List[tuple]):

460 """

461 Args:

462 changes: List of (element, old_position, old_size) tuples

463 """

464 self.changes = changes

465 self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]

466

467 def execute(self):

468 """Sizes have already been set by AlignmentManager"""

469 pass

470

471 def undo(self):

472 """Restore old positions and sizes"""

473 for element, old_position, old_size in self.changes:

474 element.position = old_position

475 element.size = old_size

476

477 def redo(self):

478 """Re-apply new sizes"""

479 for element, new_position, new_size in self.new_states:

480 element.position = new_position

481 element.size = new_size

482

483 def serialize(self) -> Dict[str, Any]:

484 """Serialize to dictionary"""

485 return {

486 "type": "resize_elements",

487 "changes": [

488 {"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}

489 for elem, old_pos, old_size in self.changes

490 ],

491 }

492

493 @staticmethod

494 def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":

495 """Deserialize from dictionary"""

496 changes = []

497 for change_data in data.get("changes", []):

498 try:

499 element = _deserialize_element(change_data["element"])

500 old_position = tuple(change_data["old_position"])

501 old_size = tuple(change_data["old_size"])

502 changes.append((element, old_position, old_size))

503 except ValueError:

504 continue

505 return ResizeElementsCommand(changes)

506

507

508 class ChangeZOrderCommand(Command):

509 """Command for changing element z-order (list position)"""

510

511 def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):

512 self.page_layout = page_layout

513 self.element = element

514 self.old_index = old_index

515 self.new_index = new_index

516

517 def execute(self):

518 """Move element to new position in list"""

519 elements = self.page_layout.elements

520 if self.element in elements:

521 elements.remove(self.element)

522 elements.insert(self.new_index, self.element)

523

524 def undo(self):

525 """Move element back to old position in list"""

526 elements = self.page_layout.elements

527 if self.element in elements:

528 elements.remove(self.element)

529 elements.insert(self.old_index, self.element)

530

531 def redo(self):

532 """Move element to new position again"""

533 self.execute()

534

535 def serialize(self) -> Dict[str, Any]:

536 """Serialize to dictionary"""

537 return {

538 "type": "change_zorder",

539 "element": self.element.serialize(),

540 "old_index": self.old_index,

541 "new_index": self.new_index,

542 }

543

544 @staticmethod

545 def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":

546 """Deserialize from dictionary"""

547 element = _deserialize_element(data["element"])

548 return ChangeZOrderCommand(

549 None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory

550 )

551

552

553 class StateChangeCommand(Command):

554 """

555 Generic command for operations that change state.

556

557 This command captures before/after snapshots of state and can restore them.

558 Used by the @undoable_operation decorator.

559 """

560

561 def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):

562 """

563 Args:

564 description: Human-readable description of the operation

565 restore_func: Function to restore state: restore_func(state)

566 before_state: State before the operation

567 after_state: State after the operation (captured during execute)

568 """

569 self.description = description

570 self.restore_func = restore_func

571 self.before_state = before_state

572 self.after_state = after_state

573

574 def execute(self):

575 """State is already applied, just store after_state if not set"""

576 # After state is captured by decorator after operation runs

577 pass

578

579 def undo(self):

580 """Restore to before state"""

581 self.restore_func(self.before_state)

582

583 def redo(self):

584 """Restore to after state"""

585 self.restore_func(self.after_state)

586

587 def serialize(self) -> Dict[str, Any]:

588 """Serialize to dictionary"""

589 # For now, state change commands are not serialized

590 # This could be enhanced later if needed

591 return {"type": "state_change", "description": self.description}

592

593 @staticmethod

594 def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":

595 """Deserialize from dictionary"""

596 # Not implemented - would need to serialize state

597 raise NotImplementedError("StateChangeCommand deserialization not yet supported")

598

599

600 class CommandHistory:

601 """Manages undo/redo command history"""

602

603 def __init__(self, max_history: int = 100, asset_manager=None, project=None):

604 self.undo_stack: List[Command] = []

605 self.redo_stack: List[Command] = []

606 self.max_history = max_history

607 self.asset_manager = asset_manager

608 self.project = project # Reference to project for dirty flag tracking

609

610 def execute(self, command: Command):

611 """Execute a command and add it to history"""

612 command.execute()

613

614 # When clearing redo stack, release asset references

615 for cmd in self.redo_stack:

616 self._release_command_assets(cmd)

617 self.redo_stack.clear()

618

619 self.undo_stack.append(command)

620

621 # Mark project as dirty

622 if self.project:

623 self.project.mark_dirty()

624

625 # Limit history size - release assets from old commands

626 if len(self.undo_stack) > self.max_history:

627 old_cmd = self.undo_stack.pop(0)

628 self._release_command_assets(old_cmd)

629

630 def _release_command_assets(self, command: Command):

631 """Release asset references held by a command"""

632 if not self.asset_manager:

633 return

634

635 # Release asset references for commands that hold them

636 if isinstance(command, (AddElementCommand, DeleteElementCommand)):

637 if isinstance(command.element, ImageData) and command.element.image_path:

638 # Convert absolute path to relative for asset manager

639 asset_path = command.element.image_path

640 if os.path.isabs(asset_path):

641 asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)

642 self.asset_manager.release_reference(asset_path)

643

644 def undo(self) -> bool:

645 """Undo the last command"""

646 if not self.can_undo():

647 return False

648

649 command = self.undo_stack.pop()

650 command.undo()

651 self.redo_stack.append(command)

652

653 # Mark project as dirty

654 if self.project:

655 self.project.mark_dirty()

656

657 return True

658

659 def redo(self) -> bool:

660 """Redo the last undone command"""

661 if not self.can_redo():

662 return False

663

664 command = self.redo_stack.pop()

665 command.redo()

666 self.undo_stack.append(command)

667

668 # Mark project as dirty

669 if self.project:

670 self.project.mark_dirty()

671

672 return True

673

674 def can_undo(self) -> bool:

675 """Check if undo is available"""

676 return len(self.undo_stack) > 0

677

678 def can_redo(self) -> bool:

679 """Check if redo is available"""

680 return len(self.redo_stack) > 0

681

682 def clear(self):

683 """Clear all history and release asset references"""

684 # Release all asset references

685 for cmd in self.undo_stack:

686 self._release_command_assets(cmd)

687 for cmd in self.redo_stack:

688 self._release_command_assets(cmd)

689

690 self.undo_stack.clear()

691 self.redo_stack.clear()

692

693 def serialize(self) -> Dict[str, Any]:

694 """Serialize history to dictionary"""

695 return {

696 "undo_stack": [cmd.serialize() for cmd in self.undo_stack],

697 "redo_stack": [cmd.serialize() for cmd in self.redo_stack],

698 "max_history": self.max_history,

699 }

700

701 def deserialize(self, data: Dict[str, Any], project):

702 """Deserialize history from dictionary"""

703 self.max_history = data.get("max_history", 100)

704

705 # Deserialize undo stack

706 self.undo_stack = []

707 for cmd_data in data.get("undo_stack", []):

708 cmd = self._deserialize_command(cmd_data, project)

709 if cmd:

710 # Fix up page_layout references for commands that need them

711 self._fixup_page_layout(cmd, project)

712 self.undo_stack.append(cmd)

713

714 # Deserialize redo stack

715 self.redo_stack = []

716 for cmd_data in data.get("redo_stack", []):

717 cmd = self._deserialize_command(cmd_data, project)

718 if cmd:

719 # Fix up page_layout references for commands that need them

720 self._fixup_page_layout(cmd, project)

721 self.redo_stack.append(cmd)

722

723 def _fixup_page_layout(self, cmd: Command, project):

724 """

725 Fix up page_layout references after deserialization.

726

727 Commands like AddElementCommand store page_layout as None during

728 deserialization because the page_layout object doesn't exist yet.

729 This method finds the correct page_layout based on the element.

730 """

731 # Check if command has a page_layout attribute that's None

732 if not hasattr(cmd, "page_layout") or cmd.page_layout is not None:

733 return

734

735 # Try to find the page containing this element

736 if hasattr(cmd, "element") and cmd.element:

737 element = cmd.element

738 for page in project.pages:

739 if element in page.layout.elements:

740 cmd.page_layout = page.layout

741 return

742 # Element not found in any page - use first page as fallback

743 # This can happen for newly added elements not yet in a page

744 if project.pages:

745 cmd.page_layout = project.pages[0].layout

746

747 # Command type registry for deserialization

748 _COMMAND_DESERIALIZERS = {

749 "add_element": AddElementCommand.deserialize,

750 "delete_element": DeleteElementCommand.deserialize,

751 "move_element": MoveElementCommand.deserialize,

752 "resize_element": ResizeElementCommand.deserialize,

753 "rotate_element": RotateElementCommand.deserialize,

754 "align_elements": AlignElementsCommand.deserialize,

755 "resize_elements": ResizeElementsCommand.deserialize,

756 "change_zorder": ChangeZOrderCommand.deserialize,

757 "adjust_image_crop": AdjustImageCropCommand.deserialize,

758 }

759

760 def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:

761 """Deserialize a single command using registry pattern"""

762 cmd_type = data.get("type")

763 if cmd_type is None:

764 return None

765

766 deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)

767 if not deserializer:

768 print(f"Warning: Unknown command type: {cmd_type}")

769 return None

770

771 try:

772 return deserializer(data, project)

773 except Exception as e:

774 print(f"Error deserializing command: {e}")

775 return None