diff --git a/Se.icon.json b/Se.icon.json new file mode 100644 index 0000000..f0f6c76 --- /dev/null +++ b/Se.icon.json @@ -0,0 +1,25 @@ +{ + "shapes": { + "2a5bf670-c821-442b-9d46-1b07faa70722": { + "layer": 0, + "type": "text", + "pos": [ + -48, + -32 + ], + "width": 96.0, + "height": 64.0, + "color": "#000000ff", + "bold": true, + "italic": false, + "size": 64.0, + "text": "Se" + } + }, + "port_positions": { + "5b6a8a0c-0875-402c-bd74-11d086aac372": [ + -8, + -8 + ] + } +} diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index 3a2c940..68ca35a 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -2,18 +2,21 @@ from collections.abc import Callable from functools import partial from typing import Protocol -from PySide6.QtCore import QObject, QPoint, Qt -from PySide6.QtWidgets import QDialog, QMenu +from PySide6.QtCore import QObject, QPoint, QSize, Qt +from PySide6.QtWidgets import QDialog, QHeaderView, QMenu -from bedit_core.models import Component, Port, PortID, Parameter, ParameterID +from bedit_core.models import Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document +from bedit_gui.models import Icon from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.icon_editor_window import IconEditorWindow from bedit_gui.views.main_window import MainWindow from bedit_gui.views.models.document_tree_model import DocumentTreeModel +from bedit_gui.utils.icon import render_icon +ICON_SIZE = QSize(32, 32) class InterfaceEditorLike(Protocol): def exec(self) -> int: ... @@ -51,13 +54,20 @@ class DocumentTreeController(QObject): self.interface_editor_factory = interface_editor_factory self.param_editor_factory = param_editor_factory self._icon_editors: list[IconEditorWindow] = [] + self._components: dict[ComponentID, Component] = {} window.ui.documentTree.setModel(self.model) document.model_changed.connect(self._on_document_changed) + document.icon_changed.connect(self._on_icon_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) window.ui.documentTree.setHeaderHidden(True) + window.ui.documentTree.setIconSize(QSize(48, 48)) + window.ui.documentTree.header().setStretchLastSection(False) + window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + window.ui.documentTree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + window.ui.documentTree.setColumnWidth(1, 56) window.ui.documentTree.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu ) @@ -70,11 +80,28 @@ class DocumentTreeController(QObject): def _on_document_changed(self, model: CoreDocument) -> None: """Rebuild the tree whenever New/Open replaces the core document.""" self.model.set_document(model) + self._components = {} + self._collect_components(model.root) + for component_id, component in self._components.items(): + icon = self.document.component_icon(component_id) + self.model.set_component_icon(component_id, render_icon(icon, component.interface.ports, ICON_SIZE)) # Optional presentation behavior. Later, you could instead remember # expanded component IDs and restore only those nodes. self.window.ui.documentTree.expandAll() + def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None: + component = self._components.get(component_id) + if component is None: + return + self.model.set_component_icon(component_id, render_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE)) + + def _collect_components(self, components: dict[ComponentID, Component]) -> None: + for component_id, component in components.items(): + self._components[component_id] = component + if isinstance(component.implementation, GraphImplementation): + self._collect_components(component.implementation.graph.components) + def _show_context_menu(self, position: QPoint) -> None: index = self.window.ui.documentTree.indexAt(position) component = self.model.value(index) diff --git a/src/bedit_gui/utils/icon.py b/src/bedit_gui/utils/icon.py index 51acf79..0dfe220 100644 --- a/src/bedit_gui/utils/icon.py +++ b/src/bedit_gui/utils/icon.py @@ -1,10 +1,13 @@ from __future__ import annotations -from PySide6.QtCore import QRectF +from PySide6.QtCore import QRectF, QSize, Qt +from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap -from bedit_gui.models import Icon, Line, Rectangle, Text +from bedit_core.models import Port, PortID, SignalDirection +from bedit_gui.models import Icon, Line, LineType, Rectangle, Text PORT_SIZE = 16 +DEFAULT_ICON_SIZE = QSize(48, 48) def get_bounding_box(icon: Icon) -> QRectF: @@ -30,3 +33,64 @@ def get_bounding_box(icon: Icon) -> QRectF: right = max(point[0] for point in points) bottom = max(point[1] for point in points) return QRectF(left, top, right - left, bottom - top) + + +def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon: + pixmap = QPixmap(size) + pixmap.fill(Qt.GlobalColor.transparent) + bounds = get_bounding_box(icon) + if not icon.shapes and not icon.port_positions: + return QIcon(pixmap) + + available_width = max(1, size.width() - 4) + available_height = max(1, size.height() - 4) + scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height())) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.translate(size.width() / 2, size.height() / 2) + painter.scale(scale, scale) + painter.translate(-bounds.center()) + + for shape in sorted(icon.shapes.values(), key=lambda item: item.layer): + if isinstance(shape, Rectangle): + painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color)) + painter.setBrush(QBrush(_color(shape.fill_color))) + painter.drawRoundedRect(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), shape.corner_radius, shape.corner_radius) + elif isinstance(shape, Text): + font = QFont() + font.setPixelSize(max(1, round(shape.size))) + font.setBold(shape.bold) + font.setItalic(shape.italic) + painter.setFont(font) + painter.setPen(_color(shape.color)) + painter.drawText(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, shape.text) + elif isinstance(shape, Line): + painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color)) + painter.drawLine(shape.pos[0], shape.pos[1], shape.end[0], shape.end[1]) + + if render_ports: + painter.setPen(QPen(QColor("#000000"))) + for port_id, position in icon.port_positions.items(): + port = ports.get(port_id) + if port is None: + continue + color = QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff") + painter.setBrush(QBrush(color)) + painter.drawRect(position[0], position[1], PORT_SIZE, PORT_SIZE) + + painter.end() + return QIcon(pixmap) + + +def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen: + styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine} + if line_type is LineType.NONE: + return QPen(Qt.PenStyle.NoPen) + return QPen(_color(color), thickness, styles[line_type]) + + +def _color(value: str) -> QColor: + color = value.removeprefix("#") + if len(color) == 8: + return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16)) + return QColor(value) diff --git a/src/bedit_gui/views/models/document_tree_model.py b/src/bedit_gui/views/models/document_tree_model.py index 2c54869..121b2bf 100644 --- a/src/bedit_gui/views/models/document_tree_model.py +++ b/src/bedit_gui/views/models/document_tree_model.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal +from PySide6.QtGui import QIcon from bedit_core.models import Component, ComponentID, GraphImplementation from bedit_core.models import Document as CoreDocument @@ -12,6 +13,7 @@ from bedit_core.models import Document as CoreDocument class DocumentTreeNode: name: str value: object + component_id: ComponentID | None parent: DocumentTreeNode | None children: list[DocumentTreeNode] @@ -23,23 +25,40 @@ class DocumentTreeModel(QAbstractItemModel): def __init__(self) -> None: super().__init__() self._document: CoreDocument | None = None - self._root = DocumentTreeNode("Document", None, None, []) + self._root = DocumentTreeNode("Document", None, None, None, []) + self._component_icons: dict[ComponentID, QIcon] = {} + self._component_nodes: dict[ComponentID, DocumentTreeNode] = {} def set_document(self, document: CoreDocument) -> None: self.beginResetModel() self._document = document + self._component_icons = {} + self._component_nodes = {} self._root = self._build_tree(document) self.endResetModel() + def set_component_icon(self, component_id: ComponentID, icon: QIcon) -> None: + node = self._component_nodes.get(component_id) + if node is None or node.parent is None: + return + self._component_icons[component_id] = icon + row = node.parent.children.index(node) + index = self.createIndex(row, 1, node) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DecorationRole]) + def rowCount(self, parent: QModelIndex | None = None) -> int: + if parent is not None and parent.isValid() and parent.column() != 0: + return 0 return len(self._node(parent).children) def columnCount(self, _parent: QModelIndex | None = None) -> int: - return 1 + return 2 def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex: + if parent is not None and parent.isValid() and parent.column() != 0: + return QModelIndex() parent_node = self._node(parent) - if column != 0 or row < 0 or row >= len(parent_node.children): + if column not in (0,1) or row < 0 or row >= len(parent_node.children): return QModelIndex() return self.createIndex(row, column, parent_node.children[row]) @@ -69,13 +88,19 @@ class DocumentTreeModel(QAbstractItemModel): if not isinstance(node, DocumentTreeNode): return None - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return node.name + if index.column() == 0: + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return node.name + elif index.column() == 1: + if role == Qt.ItemDataRole.DecorationRole: + return self._component_icons.get(node.component_id) + if role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignmentFlag.AlignCenter return None def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool: - if role != Qt.ItemDataRole.EditRole or not index.isValid(): + if role != Qt.ItemDataRole.EditRole or not index.isValid() or index.column() != 0: return False node = index.internalPointer() @@ -115,7 +140,7 @@ class DocumentTreeModel(QAbstractItemModel): node = index.internalPointer() # Make the document root node editable - if isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)): + if index.column() == 0 and isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)): flags |= Qt.ItemFlag.ItemIsEditable return flags @@ -125,17 +150,19 @@ class DocumentTreeModel(QAbstractItemModel): root = DocumentTreeNode( name="", value=None, + component_id=None, parent=None, children=[], ) # Add itself as a child so the document root is visible in the tree - document_root = DocumentTreeNode(name=document.name, value=document, parent=root, children=[]) + document_root = DocumentTreeNode(name=document.name, value=document, component_id=None, parent=root, children=[]) root.children.append(document_root) def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None: - for component in components.values(): - component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[]) + for component_id, component in components.items(): + component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=root, children=[]) root.children.append(component_node) + self._component_nodes[component_id] = component_node if isinstance(component.implementation, GraphImplementation): _list_children(component_node, component.implementation.graph.components) diff --git a/untitled.bedit.json b/untitled.bedit.json index 6f97a47..562cdc7 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -438,8 +438,8 @@ -32, -32 ], - "width": 64, - "height": 64, + "width": 64.0, + "height": 64.0, "color": "#000000ff", "bold": true, "italic": false, @@ -492,8 +492,8 @@ -32, -32 ], - "width": 64, - "height": 64, + "width": 64.0, + "height": 64.0, "color": "#000000ff", "bold": true, "italic": false, @@ -521,8 +521,8 @@ -32, -32 ], - "width": 64, - "height": 64, + "width": 64.0, + "height": 64.0, "color": "#000000ff", "bold": true, "italic": false, @@ -546,8 +546,8 @@ -32, -32 ], - "width": 64, - "height": 64, + "width": 64.0, + "height": 64.0, "color": "#000000ff", "bold": true, "italic": false, @@ -561,6 +561,31 @@ -8 ] } + }, + "2804f2f1-6123-4a53-aff1-87a3a8202711": { + "shapes": { + "2a5bf670-c821-442b-9d46-1b07faa70722": { + "layer": 0, + "type": "text", + "pos": [ + -48, + -32 + ], + "width": 96.0, + "height": 64.0, + "color": "#000000ff", + "bold": true, + "italic": false, + "size": 64.0, + "text": "Se" + } + }, + "port_positions": { + "5b6a8a0c-0875-402c-bd74-11d086aac372": [ + -8, + -8 + ] + } } } }