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, QShowEvent from PySide6.QtWidgets import QDialog, QFileDialog, QGraphicsView, QMainWindow, QMessageBox, QWidget from bedit_core.models import Port, PortID, SignalDirection from bedit_gui.models import Icon, Shape, ShapeID from bedit_gui.services import icon_files from bedit_gui.services.application_logging import get_logger from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool, TextCreationTool from bedit_gui.views.shape_options_dialog import ShapeOptionsDialog logger = get_logger(__name__) 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 ChangeShapeCommand(QUndoCommand): def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None: super().__init__(f"Change {new_shape.type}") self.editor = editor self.shape_id = shape_id self.old_shape = deepcopy(old_shape) self.new_shape = deepcopy(new_shape) def redo(self) -> None: self.editor._change_shape(self.shape_id, self.new_shape) def undo(self) -> None: self.editor._change_shape(self.shape_id, self.old_shape) class DeleteShapesCommand(QUndoCommand): def __init__(self, editor: IconEditorWindow, shapes: dict[ShapeID, Shape]) -> None: text = "Delete shape" if len(shapes) == 1 else "Delete shapes" super().__init__(text) self.editor = editor self.shapes = deepcopy(shapes) def redo(self) -> None: self.editor._remove_shapes(self.shapes) def undo(self) -> None: self.editor._restore_shapes(self.shapes) class MovePortCommand(QUndoCommand): def __init__(self, editor: IconEditorWindow, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None: super().__init__("Move port") self.editor = editor self.port_id = port_id self.old_position = old_position self.new_position = new_position def redo(self) -> None: self.editor._move_port(self.port_id, self.new_position) def undo(self) -> None: self.editor._move_port(self.port_id, self.old_position) 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, ports: dict[PortID, Port], 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._ports = deepcopy(ports) self.scene = IconGraphicsScene(self) self.scene.set_ports(self._ports) self._ensure_port_positions(self._ports) self.scene.set_icon(self._icon) self.scene.port_moved.connect(self._port_moved) self.scene.shape_created.connect(self._shape_created) self.scene.shape_changed.connect(self._shape_changed) self.scene.shape_options_requested.connect(self._show_shape_options) 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.ui.actionAdd_Text.setCheckable(True) self.ui.actionAdd_Text.triggered.connect(self._start_text_tool) self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool) self.delete_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Delete), self) self.delete_shortcut.activated.connect(self._delete_selected_shapes) 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.actionSave_to_File.triggered.connect(self.save_to_file) self.ui.actionOpen_from_File.triggered.connect(self.open_from_file) 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 showEvent(self, event: QShowEvent) -> None: super().showEvent(event) self.fit_scene() 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 save_to_file(self) -> None: file_name, _ = QFileDialog.getSaveFileName(self, "Save Icon", "", "JSON files (*.json)") if not file_name: return path = file_name if file_name.lower().endswith(".json") else f"{file_name}.json" try: icon_files.save(self._icon, path) except (OSError, TypeError, ValueError) as exc: logger.exception("Could not save icon to %s", path) QMessageBox.critical(self, "Could not save icon", str(exc)) return logger.info("Saved icon to: %s", path) def open_from_file(self) -> None: file_name, _ = QFileDialog.getOpenFileName(self, "Open Icon", "", "JSON files (*.json)") if not file_name: return try: icon = icon_files.load(file_name) except (OSError, TypeError, ValueError) as exc: logger.exception("Could not open icon from %s", file_name) QMessageBox.critical(self, "Could not open icon", str(exc)) return self.apply_change(icon, "Load icon from file") logger.info("Loaded icon from: %s", file_name) 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.fit_scene() def fit_scene(self) -> None: self.ui.graphicsView.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) 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._ensure_port_positions(self._ports) 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 _remove_shapes(self, shapes: dict[ShapeID, Shape]) -> None: for shape_id in shapes: self._icon.shapes.pop(shape_id, None) self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) def _restore_shapes(self, shapes: dict[ShapeID, Shape]) -> None: self._icon.shapes.update(deepcopy(shapes)) self.scene.set_icon(self._icon) self.icon_changed.emit(self.icon()) def _change_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 _move_port(self, port_id: PortID, position: tuple[int, int]) -> None: self._icon.port_positions[port_id] = position 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 _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None: self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape)) def _port_moved(self, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None: self.undo_stack.push(MovePortCommand(self, port_id, old_position, new_position)) def _delete_selected_shapes(self) -> None: shape_ids = self.scene.selected_shape_ids() shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in shape_ids} if shapes: self.undo_stack.push(DeleteShapesCommand(self, shapes)) def _show_shape_options(self, shape_id: ShapeID) -> None: old_shape = self._icon.shapes.get(shape_id) if old_shape is None: return dialog = ShapeOptionsDialog(old_shape, self.scene.sceneRect(), self) if dialog.exec() == QDialog.DialogCode.Accepted: new_shape = dialog.shape() if new_shape != old_shape: self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape)) def _ensure_port_positions(self, ports: dict[PortID, Port]) -> None: bounds = self.scene.sceneRect() left = round(bounds.left()) top = round(bounds.top()) right = round(bounds.right() - 16) bottom = round(bounds.bottom() - 16) positions: dict[PortID, tuple[int, int]] = {} input_index = 0 output_index = 0 for port_id, port in ports.items(): if port.direction is SignalDirection.INPUT: default = (left, top + input_index * 16) input_index += 1 else: default = (right, top + output_index * 16) output_index += 1 position = self._icon.port_positions.get(port_id, default) positions[port_id] = (max(left, min(right, position[0])), max(top, min(bottom, position[1]))) self._icon.port_positions = positions def _start_rectangle_tool(self) -> None: layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 self.ui.actionAdd_Text.setChecked(False) self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer)) def _start_text_tool(self) -> None: layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 self.ui.actionAdd_Rectangle.setChecked(False) self.scene.set_creation_tool(TextCreationTool(self.scene, layer)) def _tool_active_changed(self, active: bool) -> None: if not active: self.ui.actionAdd_Rectangle.setChecked(False) self.ui.actionAdd_Text.setChecked(False) 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")