327 lines
14 KiB
Python
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")
|