Icon in document tree

This commit is contained in:
2026-07-27 15:15:13 +02:00
parent 54788583a4
commit a4daaa8798
5 changed files with 191 additions and 23 deletions

25
Se.icon.json Normal file
View File

@@ -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
]
}
}

View File

@@ -2,18 +2,21 @@ from collections.abc import Callable
from functools import partial from functools import partial
from typing import Protocol from typing import Protocol
from PySide6.QtCore import QObject, QPoint, Qt from PySide6.QtCore import QObject, QPoint, QSize, Qt
from PySide6.QtWidgets import QDialog, QMenu 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_core.models import Document as CoreDocument
from bedit_gui.documents import Document 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.interface_editor_dialog import InterfaceEditorDialog
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
from bedit_gui.views.icon_editor_window import IconEditorWindow from bedit_gui.views.icon_editor_window import IconEditorWindow
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel 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): class InterfaceEditorLike(Protocol):
def exec(self) -> int: ... def exec(self) -> int: ...
@@ -51,13 +54,20 @@ class DocumentTreeController(QObject):
self.interface_editor_factory = interface_editor_factory self.interface_editor_factory = interface_editor_factory
self.param_editor_factory = param_editor_factory self.param_editor_factory = param_editor_factory
self._icon_editors: list[IconEditorWindow] = [] self._icon_editors: list[IconEditorWindow] = []
self._components: dict[ComponentID, Component] = {}
window.ui.documentTree.setModel(self.model) window.ui.documentTree.setModel(self.model)
document.model_changed.connect(self._on_document_changed) 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_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component) self.model.rename_component_requested.connect(self.document.rename_component)
window.ui.documentTree.setHeaderHidden(True) 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( window.ui.documentTree.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu Qt.ContextMenuPolicy.CustomContextMenu
) )
@@ -70,11 +80,28 @@ class DocumentTreeController(QObject):
def _on_document_changed(self, model: CoreDocument) -> None: def _on_document_changed(self, model: CoreDocument) -> None:
"""Rebuild the tree whenever New/Open replaces the core document.""" """Rebuild the tree whenever New/Open replaces the core document."""
self.model.set_document(model) 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 # Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes. # expanded component IDs and restore only those nodes.
self.window.ui.documentTree.expandAll() 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: def _show_context_menu(self, position: QPoint) -> None:
index = self.window.ui.documentTree.indexAt(position) index = self.window.ui.documentTree.indexAt(position)
component = self.model.value(index) component = self.model.value(index)

View File

@@ -1,10 +1,13 @@
from __future__ import annotations 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 PORT_SIZE = 16
DEFAULT_ICON_SIZE = QSize(48, 48)
def get_bounding_box(icon: Icon) -> QRectF: 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) right = max(point[0] for point in points)
bottom = max(point[1] for point in points) bottom = max(point[1] for point in points)
return QRectF(left, top, right - left, bottom - top) 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)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal 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 Component, ComponentID, GraphImplementation
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
@@ -12,6 +13,7 @@ from bedit_core.models import Document as CoreDocument
class DocumentTreeNode: class DocumentTreeNode:
name: str name: str
value: object value: object
component_id: ComponentID | None
parent: DocumentTreeNode | None parent: DocumentTreeNode | None
children: list[DocumentTreeNode] children: list[DocumentTreeNode]
@@ -23,23 +25,40 @@ class DocumentTreeModel(QAbstractItemModel):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._document: CoreDocument | None = None 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: def set_document(self, document: CoreDocument) -> None:
self.beginResetModel() self.beginResetModel()
self._document = document self._document = document
self._component_icons = {}
self._component_nodes = {}
self._root = self._build_tree(document) self._root = self._build_tree(document)
self.endResetModel() 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: 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) return len(self._node(parent).children)
def columnCount(self, _parent: QModelIndex | None = None) -> int: def columnCount(self, _parent: QModelIndex | None = None) -> int:
return 1 return 2
def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex: 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) 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 QModelIndex()
return self.createIndex(row, column, parent_node.children[row]) return self.createIndex(row, column, parent_node.children[row])
@@ -69,13 +88,19 @@ class DocumentTreeModel(QAbstractItemModel):
if not isinstance(node, DocumentTreeNode): if not isinstance(node, DocumentTreeNode):
return None return None
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): if index.column() == 0:
return node.name 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 return None
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool: 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 return False
node = index.internalPointer() node = index.internalPointer()
@@ -115,7 +140,7 @@ class DocumentTreeModel(QAbstractItemModel):
node = index.internalPointer() node = index.internalPointer()
# Make the document root node editable # 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 flags |= Qt.ItemFlag.ItemIsEditable
return flags return flags
@@ -125,17 +150,19 @@ class DocumentTreeModel(QAbstractItemModel):
root = DocumentTreeNode( root = DocumentTreeNode(
name="", name="",
value=None, value=None,
component_id=None,
parent=None, parent=None,
children=[], children=[],
) )
# Add itself as a child so the document root is visible in the tree # 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) root.children.append(document_root)
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None: def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
for component in components.values(): for component_id, component in components.items():
component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[]) component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=root, children=[])
root.children.append(component_node) root.children.append(component_node)
self._component_nodes[component_id] = component_node
if isinstance(component.implementation, GraphImplementation): if isinstance(component.implementation, GraphImplementation):
_list_children(component_node, component.implementation.graph.components) _list_children(component_node, component.implementation.graph.components)

View File

@@ -438,8 +438,8 @@
-32, -32,
-32 -32
], ],
"width": 64, "width": 64.0,
"height": 64, "height": 64.0,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "italic": false,
@@ -492,8 +492,8 @@
-32, -32,
-32 -32
], ],
"width": 64, "width": 64.0,
"height": 64, "height": 64.0,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "italic": false,
@@ -521,8 +521,8 @@
-32, -32,
-32 -32
], ],
"width": 64, "width": 64.0,
"height": 64, "height": 64.0,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "italic": false,
@@ -546,8 +546,8 @@
-32, -32,
-32 -32
], ],
"width": 64, "width": 64.0,
"height": 64, "height": 64.0,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "italic": false,
@@ -561,6 +561,31 @@
-8 -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
]
}
} }
} }
} }