from __future__ import annotations from copy import deepcopy from PySide6.QtCore import QEvent, QObject, Qt, Signal from PySide6.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent from PySide6.QtWidgets import QGraphicsView, QMainWindow, QWidget from bedit_gui.models import Icon, Shape, ShapeID from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool class ChangeIconDraftCommand(QUndoCommand): def __init__(self, editor: IconEditorWindow, icon: Icon, text: str) -> None: super().__init__(text) self.editor = editor self.old_icon = editor.icon() self.new_icon = deepcopy(icon) def redo(self) -> None: self.editor._set_icon(self.new_icon) def undo(self) -> None: self.editor._set_icon(self.old_icon) class AddShapeCommand(QUndoCommand): def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, shape: Shape) -> None: super().__init__(f"Add {shape.type}") self.editor = editor self.shape_id = shape_id self.shape = deepcopy(shape) def redo(self) -> None: self.editor._add_shape(self.shape_id, self.shape) def undo(self) -> None: self.editor._remove_shape(self.shape_id) class IconEditorWindow(QMainWindow): """Independent icon editing session with its own undo stack.""" saved = Signal(object) icon_changed = Signal(object) zoom_step = 1.2 minimum_zoom = 0.1 maximum_zoom = 10.0 def __init__(self, icon: Icon, parent: QWidget | None = None) -> None: super().__init__(parent) self.ui = Ui_iconEditor() self.ui.setupUi(self) self.setWindowTitle("Icon Editor") self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._icon = deepcopy(icon) self.scene = IconGraphicsScene(self) self.scene.set_icon(self._icon) self.scene.shape_created.connect(self._shape_created) self.scene.tool_active_changed.connect(self._tool_active_changed) self.ui.graphicsView.setScene(self.scene) self.ui.graphicsView.setRenderHint(QPainter.RenderHint.Antialiasing) self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.ui.graphicsView.viewport().installEventFilter(self) self.ui.actionAdd_Rectangle.setCheckable(True) self.ui.actionAdd_Rectangle.triggered.connect(self._start_rectangle_tool) self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool) self.zoom_in_shortcut = QShortcut(QKeySequence("Ctrl++"), self) self.zoom_in_alt_shortcut = QShortcut(QKeySequence("Ctrl+="), self) self.zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self) self.zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self) self.zoom_in_shortcut.activated.connect(self.zoom_in) self.zoom_in_alt_shortcut.activated.connect(self.zoom_in) self.zoom_out_shortcut.activated.connect(self.zoom_out) self.zoom_reset_shortcut.activated.connect(self.reset_zoom) self.undo_stack = QUndoStack(self) self.ui.actionUndo.triggered.connect(self.undo_stack.undo) self.ui.actionRedo.triggered.connect(self.undo_stack.redo) self.ui.actionSave.triggered.connect(self.save) self.ui.actionCancel.triggered.connect(self.close) self.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled) self.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled) self.undo_stack.undoTextChanged.connect(self._update_undo_text) self.undo_stack.redoTextChanged.connect(self._update_redo_text) self.ui.actionUndo.setEnabled(False) self.ui.actionRedo.setEnabled(False) def icon(self) -> Icon: return deepcopy(self._icon) def apply_change(self, icon: Icon, text: str = "Edit icon") -> None: self.undo_stack.push(ChangeIconDraftCommand(self, icon, text)) def save(self) -> None: self.saved.emit(self.icon()) self.close() def zoom_in(self) -> None: self._zoom(self.zoom_step) def zoom_out(self) -> None: self._zoom(1 / self.zoom_step) def reset_zoom(self) -> None: self.ui.graphicsView.resetTransform() def eventFilter(self, watched: QObject, event: QEvent) -> bool: if watched is self.ui.graphicsView.viewport() and isinstance(event, QWheelEvent): if event.angleDelta().y() == 0: return True self.zoom_in() if event.angleDelta().y() > 0 else self.zoom_out() event.accept() return True return super().eventFilter(watched, event) def _zoom(self, factor: float) -> None: current = self.ui.graphicsView.transform().m11() target = max(self.minimum_zoom, min(self.maximum_zoom, current * factor)) if target != current: factor = target / current self.ui.graphicsView.scale(factor, factor) def _set_icon(self, icon: Icon) -> None: self._icon = deepcopy(icon) self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) def _add_shape(self, shape_id: ShapeID, shape: Shape) -> None: self._icon.shapes[shape_id] = deepcopy(shape) self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) def _remove_shape(self, shape_id: ShapeID) -> None: self._icon.shapes.pop(shape_id, None) self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) def _shape_created(self, shape: Shape) -> None: self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape)) def _start_rectangle_tool(self) -> None: layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer)) def _tool_active_changed(self, active: bool) -> None: self.ui.actionAdd_Rectangle.setChecked(active) cursor = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor self.ui.graphicsView.viewport().setCursor(cursor) def _update_undo_text(self, text: str) -> None: self.ui.actionUndo.setText(f"Undo {text}" if text else "Undo") def _update_redo_text(self, text: str) -> None: self.ui.actionRedo.setText(f"Redo {text}" if text else "Redo")