Files
BEdit/src/bedit_gui/views/icon_editor_window.py

327 lines
14 KiB
Python

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