Hooked up port editor in the document tree
This commit is contained in:
76
src/bedit_gui/commands/port_commands.py
Normal file
76
src/bedit_gui/commands/port_commands.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
|
||||||
|
from bedit_core.models import Component, Port, PortID
|
||||||
|
|
||||||
|
|
||||||
|
class AddPortCommand(QUndoCommand):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
component: Component,
|
||||||
|
port_id: PortID,
|
||||||
|
port: Port,
|
||||||
|
) -> None:
|
||||||
|
super().__init__("Add port")
|
||||||
|
self.document = document
|
||||||
|
self.component = component
|
||||||
|
self.port_id = port_id
|
||||||
|
self.port = deepcopy(port)
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.component.interface.ports[self.port_id] = deepcopy(self.port)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
del self.component.interface.ports[self.port_id]
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
|
||||||
|
class RemovePortCommand(QUndoCommand):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
component: Component,
|
||||||
|
port_id: PortID,
|
||||||
|
) -> None:
|
||||||
|
super().__init__("Remove port")
|
||||||
|
self.document = document
|
||||||
|
self.component = component
|
||||||
|
self.port_id = port_id
|
||||||
|
self.port = deepcopy(component.interface.ports[port_id])
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
del self.component.interface.ports[self.port_id]
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self.component.interface.ports[self.port_id] = deepcopy(self.port)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePortCommand(QUndoCommand):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
component: Component,
|
||||||
|
port_id: PortID,
|
||||||
|
port: Port,
|
||||||
|
) -> None:
|
||||||
|
super().__init__("Change port")
|
||||||
|
self.document = document
|
||||||
|
self.component = component
|
||||||
|
self.port_id = port_id
|
||||||
|
self.old_port = deepcopy(component.interface.ports[port_id])
|
||||||
|
self.new_port = deepcopy(port)
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.component.interface.ports[self.port_id] = deepcopy(self.new_port)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self.component.interface.ports[self.port_id] = deepcopy(self.old_port)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
@@ -1,18 +1,44 @@
|
|||||||
from PySide6.QtCore import QObject
|
from collections.abc import Callable
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QPoint, Qt
|
||||||
|
from PySide6.QtWidgets import QDialog, QMenu
|
||||||
|
|
||||||
|
from bedit_core.models import Component, Port, PortID
|
||||||
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.views.dialogs.interface_editor_dialog import (
|
||||||
|
InterfaceEditorDialog,
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceEditorLike(Protocol):
|
||||||
|
def exec(self) -> int: ...
|
||||||
|
|
||||||
|
def ports(self) -> dict[PortID, Port]: ...
|
||||||
|
|
||||||
|
|
||||||
|
InterfaceEditorFactory = Callable[
|
||||||
|
[dict[PortID, Port], MainWindow],
|
||||||
|
InterfaceEditorLike,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class DocumentTreeController(QObject):
|
class DocumentTreeController(QObject):
|
||||||
def __init__(self,document: Document,window: MainWindow) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
window: MainWindow,
|
||||||
|
interface_editor_factory: InterfaceEditorFactory = InterfaceEditorDialog,
|
||||||
|
) -> None:
|
||||||
super().__init__(window)
|
super().__init__(window)
|
||||||
|
|
||||||
self.document = document
|
self.document = document
|
||||||
self.window = window
|
self.window = window
|
||||||
self.model = DocumentTreeModel()
|
self.model = DocumentTreeModel()
|
||||||
|
self.interface_editor_factory = interface_editor_factory
|
||||||
|
|
||||||
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)
|
||||||
@@ -20,6 +46,12 @@ class DocumentTreeController(QObject):
|
|||||||
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.setContextMenuPolicy(
|
||||||
|
Qt.ContextMenuPolicy.CustomContextMenu
|
||||||
|
)
|
||||||
|
window.ui.documentTree.customContextMenuRequested.connect(
|
||||||
|
self._show_context_menu
|
||||||
|
)
|
||||||
|
|
||||||
self._on_document_changed(document.model)
|
self._on_document_changed(document.model)
|
||||||
|
|
||||||
@@ -30,3 +62,25 @@ class DocumentTreeController(QObject):
|
|||||||
# 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 _show_context_menu(self, position: QPoint) -> None:
|
||||||
|
index = self.window.ui.documentTree.indexAt(position)
|
||||||
|
component = self.model.value(index)
|
||||||
|
if not isinstance(component, Component):
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QMenu(self.window.ui.documentTree)
|
||||||
|
edit_interface = menu.addAction("Edit Interface")
|
||||||
|
selected = menu.exec(
|
||||||
|
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
||||||
|
)
|
||||||
|
if selected is edit_interface:
|
||||||
|
self._edit_interface(component)
|
||||||
|
|
||||||
|
def _edit_interface(self, component: Component) -> None:
|
||||||
|
dialog = self.interface_editor_factory(
|
||||||
|
component.interface.ports,
|
||||||
|
self.window,
|
||||||
|
)
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
self.document.update_component_ports(component, dialog.ports())
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ from pathlib import Path
|
|||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
from PySide6.QtGui import QUndoStack
|
from PySide6.QtGui import QUndoStack
|
||||||
|
|
||||||
from bedit_core.models import ID
|
from bedit_core.models import ID, Component, Port, PortID
|
||||||
from bedit_core.models import Document as CoreDocument, Component
|
from bedit_core.models import Document as CoreDocument
|
||||||
from bedit_gui.services import document_files
|
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
||||||
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
|
|
||||||
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
||||||
|
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
|
||||||
|
from bedit_gui.services import document_files
|
||||||
|
|
||||||
|
|
||||||
class Document(QObject):
|
class Document(QObject):
|
||||||
@@ -84,5 +85,33 @@ class Document(QObject):
|
|||||||
def rename(self, name: str) -> None:
|
def rename(self, name: str) -> None:
|
||||||
self.undo_stack.push(RenameDocumentCommand(self, name))
|
self.undo_stack.push(RenameDocumentCommand(self, name))
|
||||||
|
|
||||||
def rename_component(self, component: Component, name:str) -> None:
|
def rename_component(self, component: Component, name: str) -> None:
|
||||||
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
||||||
|
|
||||||
|
def update_component_ports(
|
||||||
|
self,
|
||||||
|
component: Component,
|
||||||
|
ports: dict[PortID, Port],
|
||||||
|
) -> None:
|
||||||
|
current = component.interface.ports
|
||||||
|
removed = [
|
||||||
|
RemovePortCommand(self, component, port_id)
|
||||||
|
for port_id in current.keys() - ports.keys()
|
||||||
|
]
|
||||||
|
added = [
|
||||||
|
AddPortCommand(self, component, port_id, ports[port_id])
|
||||||
|
for port_id in ports.keys() - current.keys()
|
||||||
|
]
|
||||||
|
changed = [
|
||||||
|
ChangePortCommand(self, component, port_id, ports[port_id])
|
||||||
|
for port_id in current.keys() & ports.keys()
|
||||||
|
if current[port_id] != ports[port_id]
|
||||||
|
]
|
||||||
|
commands = [*removed, *added, *changed]
|
||||||
|
if not commands:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.undo_stack.beginMacro("Edit interface")
|
||||||
|
for command in commands:
|
||||||
|
self.undo_stack.push(command)
|
||||||
|
self.undo_stack.endMacro()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>541</width>
|
<width>541</width>
|
||||||
<height>593</height>
|
<height>420</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="windowTitle">
|
<property name="windowTitle">
|
||||||
@@ -183,8 +183,31 @@
|
|||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="QComboBox" name="domainComboBox"/>
|
<widget class="QComboBox" name="domainComboBox"/>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="1" column="0">
|
||||||
|
<widget class="QLabel" name="causalityLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>Causality:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QComboBox" name="causalityComboBox"/>
|
||||||
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="Line" name="line_2">
|
<widget class="Line" name="line_2">
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
@@ -194,6 +217,9 @@
|
|||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<layout class="QFormLayout" name="descriptionForm">
|
<layout class="QFormLayout" name="descriptionForm">
|
||||||
|
<property name="fieldGrowthPolicy">
|
||||||
|
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
|
||||||
|
</property>
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QLabel" name="descriptionLabel">
|
<widget class="QLabel" name="descriptionLabel">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -204,7 +230,7 @@
|
|||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="QPlainTextEdit" name="descriptionEdit">
|
<widget class="QPlainTextEdit" name="descriptionEdit">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
|
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
|
||||||
<horstretch>0</horstretch>
|
<horstretch>0</horstretch>
|
||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
@@ -215,6 +241,12 @@
|
|||||||
<height>20</height>
|
<height>20</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>16777215</width>
|
||||||
|
<height>60</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
|
|||||||
40
src/bedit_gui/views/dialogs/interface_editor_dialog.py
Normal file
40
src/bedit_gui/views/dialogs/interface_editor_dialog.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bedit_core.models import Port, PortID
|
||||||
|
from bedit_gui.views.port_editor_widget import PortEditorWidget
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceEditorDialog(QDialog):
|
||||||
|
"""Modal wrapper around the reusable port editor widget."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ports: dict[PortID, Port],
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Edit Interface")
|
||||||
|
self.resize(700, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.editor = PortEditorWidget(self)
|
||||||
|
self.editor.set_ports(ports)
|
||||||
|
layout.addWidget(self.editor)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def ports(self) -> dict[PortID, Port]:
|
||||||
|
return self.editor.ports()
|
||||||
@@ -4,7 +4,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
|
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
|
||||||
|
|
||||||
from bedit_core.models import Document as CoreDocument, ComponentID, Component, GraphImplementation
|
from bedit_core.models import Component, ComponentID, GraphImplementation
|
||||||
|
from bedit_core.models import Document as CoreDocument
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -36,7 +37,7 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def index(self,row: int,column: int,parent: QModelIndex | None = None) -> QModelIndex:
|
def index(self, row: int, column: int, parent: QModelIndex | None = None) -> 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 != 0 or row < 0 or row >= len(parent_node.children):
|
||||||
return QModelIndex()
|
return QModelIndex()
|
||||||
@@ -60,7 +61,7 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
row = grandparent.children.index(parent_node)
|
row = grandparent.children.index(parent_node)
|
||||||
return self.createIndex(row, 0, parent_node)
|
return self.createIndex(row, 0, parent_node)
|
||||||
|
|
||||||
def data(self,index: QModelIndex,role: int = Qt.ItemDataRole.DisplayRole) -> object | None:
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> object | None:
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -70,10 +71,10 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
|
|
||||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||||
return node.name
|
return node.name
|
||||||
|
|
||||||
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():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -99,6 +100,12 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
node = index.internalPointer()
|
node = index.internalPointer()
|
||||||
return node if isinstance(node, DocumentTreeNode) else self._root
|
return node if isinstance(node, DocumentTreeNode) else self._root
|
||||||
|
|
||||||
|
def value(self, index: QModelIndex) -> object | None:
|
||||||
|
if not index.isValid():
|
||||||
|
return None
|
||||||
|
node = index.internalPointer()
|
||||||
|
return node.value if isinstance(node, DocumentTreeNode) else None
|
||||||
|
|
||||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||||
flags = super().flags(index)
|
flags = super().flags(index)
|
||||||
|
|
||||||
@@ -108,8 +115,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)
|
if isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
||||||
and (isinstance(node.value, CoreDocument) or isinstance(node.value, Component))):
|
|
||||||
flags |= Qt.ItemFlag.ItemIsEditable
|
flags |= Qt.ItemFlag.ItemIsEditable
|
||||||
|
|
||||||
return flags
|
return flags
|
||||||
@@ -123,26 +129,17 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
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(
|
document_root = DocumentTreeNode(name=document.name, value=document, parent=root, children=[])
|
||||||
name = document.name,
|
|
||||||
value = document,
|
|
||||||
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_id, component in components.items():
|
for component in components.values():
|
||||||
component_node = DocumentTreeNode(
|
component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[])
|
||||||
name = component.name,
|
|
||||||
value = component,
|
|
||||||
parent = root,
|
|
||||||
children = []
|
|
||||||
)
|
|
||||||
root.children.append(component_node)
|
root.children.append(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)
|
||||||
|
|
||||||
_list_children(document_root, document.root)
|
_list_children(document_root, document.root)
|
||||||
|
|
||||||
return root
|
return root
|
||||||
|
|||||||
222
src/bedit_gui/views/port_editor_widget.py
Normal file
222
src/bedit_gui/views/port_editor_widget.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from PySide6.QtCore import QItemSelection, Signal
|
||||||
|
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||||
|
from PySide6.QtWidgets import QButtonGroup, QLayout, QWidget
|
||||||
|
|
||||||
|
from bedit_core.models import BondPort, Port, PortCausality, PortID, SignalDirection, SignalPort, ValueType
|
||||||
|
from bedit_gui.ui.generated.ui_port_editor_widget import Ui_PortEditor
|
||||||
|
|
||||||
|
|
||||||
|
class PortEditorWidget(QWidget):
|
||||||
|
"""Reusable editor for a detached draft of an interface's ports."""
|
||||||
|
|
||||||
|
ports_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.ui = Ui_PortEditor()
|
||||||
|
self.ui.setupUi(self)
|
||||||
|
self._ports: dict[PortID, Port] = {}
|
||||||
|
self._port_ids: list[PortID] = []
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
self._type_group = QButtonGroup(self)
|
||||||
|
self._type_group.addButton(self.ui.typeSignal)
|
||||||
|
self._type_group.addButton(self.ui.typeBond)
|
||||||
|
self._orientation_group = QButtonGroup(self)
|
||||||
|
self._orientation_group.addButton(self.ui.inputOrientation)
|
||||||
|
self._orientation_group.addButton(self.ui.outputOrientation)
|
||||||
|
|
||||||
|
self.ui.signalTypeComboBox.addItem("Real", ValueType.REAL)
|
||||||
|
self.ui.signalTypeComboBox.addItem("Integer", ValueType.INT)
|
||||||
|
self.ui.signalTypeComboBox.addItem("Boolean", ValueType.BOOL)
|
||||||
|
self.ui.domainComboBox.setEditable(True)
|
||||||
|
for causality in PortCausality:
|
||||||
|
self.ui.causalityComboBox.addItem(causality.value.replace("_", " ").title(), causality)
|
||||||
|
|
||||||
|
self._list_model = QStandardItemModel(self)
|
||||||
|
self.ui.portList.setModel(self._list_model)
|
||||||
|
self.ui.portList.selectionModel().selectionChanged.connect(
|
||||||
|
self._selection_changed
|
||||||
|
)
|
||||||
|
self.ui.addPort.clicked.connect(self._add_port)
|
||||||
|
self.ui.removePort.clicked.connect(self._remove_port)
|
||||||
|
|
||||||
|
self.ui.nameEdit.textEdited.connect(self._form_changed)
|
||||||
|
self.ui.typeSignal.toggled.connect(self._form_changed)
|
||||||
|
self.ui.typeBond.toggled.connect(self._form_changed)
|
||||||
|
self.ui.inputOrientation.toggled.connect(self._form_changed)
|
||||||
|
self.ui.outputOrientation.toggled.connect(self._form_changed)
|
||||||
|
self.ui.widthSize.valueChanged.connect(self._form_changed)
|
||||||
|
self.ui.heightSize.valueChanged.connect(self._form_changed)
|
||||||
|
self.ui.multiplicityCheckBox.toggled.connect(self._form_changed)
|
||||||
|
self.ui.signalTypeComboBox.currentIndexChanged.connect(self._form_changed)
|
||||||
|
self.ui.domainComboBox.currentTextChanged.connect(self._form_changed)
|
||||||
|
self.ui.causalityComboBox.currentIndexChanged.connect(self._form_changed)
|
||||||
|
self.ui.descriptionEdit.textChanged.connect(self._form_changed)
|
||||||
|
|
||||||
|
self._set_editor_enabled(False)
|
||||||
|
self._update_option_visibility()
|
||||||
|
|
||||||
|
def set_ports(self, ports: dict[PortID, Port]) -> None:
|
||||||
|
self._ports = deepcopy(ports)
|
||||||
|
self._port_ids = list(self._ports)
|
||||||
|
self._rebuild_list()
|
||||||
|
|
||||||
|
def ports(self) -> dict[PortID, Port]:
|
||||||
|
return deepcopy(self._ports)
|
||||||
|
|
||||||
|
def _rebuild_list(self, selected_id: PortID | None = None) -> None:
|
||||||
|
self._list_model.clear()
|
||||||
|
for port_id in self._port_ids:
|
||||||
|
self._list_model.appendRow(QStandardItem(self._ports[port_id].name))
|
||||||
|
|
||||||
|
if self._port_ids:
|
||||||
|
if selected_id not in self._ports:
|
||||||
|
selected_id = self._port_ids[0]
|
||||||
|
row = self._port_ids.index(selected_id)
|
||||||
|
index = self._list_model.index(row, 0)
|
||||||
|
self.ui.portList.setCurrentIndex(index)
|
||||||
|
else:
|
||||||
|
self._set_editor_enabled(False)
|
||||||
|
self.ui.removePort.setEnabled(False)
|
||||||
|
|
||||||
|
def _selection_changed(self,_selected: QItemSelection,_deselected: QItemSelection) -> None:
|
||||||
|
port_id = self._selected_port_id()
|
||||||
|
self.ui.removePort.setEnabled(port_id is not None)
|
||||||
|
self._set_editor_enabled(port_id is not None)
|
||||||
|
if port_id is not None:
|
||||||
|
self._load_port(self._ports[port_id])
|
||||||
|
|
||||||
|
def _selected_port_id(self) -> PortID | None:
|
||||||
|
index = self.ui.portList.currentIndex()
|
||||||
|
if not index.isValid() or index.row() >= len(self._port_ids):
|
||||||
|
return None
|
||||||
|
return self._port_ids[index.row()]
|
||||||
|
|
||||||
|
def _load_port(self, port: Port) -> None:
|
||||||
|
self._loading = True
|
||||||
|
self.ui.nameEdit.setText(port.name)
|
||||||
|
self.ui.inputOrientation.setChecked(port.direction is SignalDirection.INPUT)
|
||||||
|
self.ui.outputOrientation.setChecked(port.direction is SignalDirection.OUTPUT)
|
||||||
|
self.ui.widthSize.setValue(port.matrix_size[0])
|
||||||
|
self.ui.heightSize.setValue(port.matrix_size[1])
|
||||||
|
self.ui.multiplicityCheckBox.setChecked(port.multiplicity)
|
||||||
|
self.ui.descriptionEdit.setPlainText(port.description or "")
|
||||||
|
|
||||||
|
if isinstance(port, SignalPort):
|
||||||
|
self.ui.typeSignal.setChecked(True)
|
||||||
|
self.ui.signalTypeComboBox.setCurrentIndex(
|
||||||
|
self.ui.signalTypeComboBox.findData(port.value_type)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ui.typeBond.setChecked(True)
|
||||||
|
assert isinstance(port, BondPort)
|
||||||
|
if self.ui.domainComboBox.findText(port.domain) < 0:
|
||||||
|
self.ui.domainComboBox.addItem(port.domain)
|
||||||
|
self.ui.domainComboBox.setCurrentText(port.domain)
|
||||||
|
self.ui.causalityComboBox.setCurrentIndex(self.ui.causalityComboBox.findData(port.causality_preference))
|
||||||
|
|
||||||
|
self._loading = False
|
||||||
|
self._update_option_visibility()
|
||||||
|
|
||||||
|
def _form_changed(self, *_args: object) -> None:
|
||||||
|
if self._loading:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._update_option_visibility()
|
||||||
|
port_id = self._selected_port_id()
|
||||||
|
if port_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_port = self._ports[port_id]
|
||||||
|
self._ports[port_id] = self._port_from_form(old_port)
|
||||||
|
self._list_model.item(self._port_ids.index(port_id)).setText(
|
||||||
|
self._ports[port_id].name
|
||||||
|
)
|
||||||
|
self.ports_changed.emit()
|
||||||
|
|
||||||
|
def _port_from_form(self, old_port: Port) -> Port:
|
||||||
|
common = {
|
||||||
|
"name": self.ui.nameEdit.text().strip(),
|
||||||
|
"direction": (
|
||||||
|
SignalDirection.INPUT
|
||||||
|
if self.ui.inputOrientation.isChecked()
|
||||||
|
else SignalDirection.OUTPUT
|
||||||
|
),
|
||||||
|
"multiplicity": self.ui.multiplicityCheckBox.isChecked(),
|
||||||
|
"matrix_size": [
|
||||||
|
self.ui.widthSize.value(),
|
||||||
|
self.ui.heightSize.value(),
|
||||||
|
],
|
||||||
|
"description": self.ui.descriptionEdit.toPlainText() or None,
|
||||||
|
}
|
||||||
|
if self.ui.typeSignal.isChecked():
|
||||||
|
return SignalPort(
|
||||||
|
**common,
|
||||||
|
value_type=self.ui.signalTypeComboBox.currentData(),
|
||||||
|
quantity=(
|
||||||
|
old_port.quantity if isinstance(old_port, SignalPort) else None
|
||||||
|
),
|
||||||
|
unit=old_port.unit if isinstance(old_port, SignalPort) else None,
|
||||||
|
)
|
||||||
|
return BondPort(
|
||||||
|
**common,
|
||||||
|
domain=self.ui.domainComboBox.currentText(),
|
||||||
|
causality_preference=self.ui.causalityComboBox.currentData(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_port(self) -> None:
|
||||||
|
port_id = PortID()
|
||||||
|
number = len(self._ports) + 1
|
||||||
|
port = SignalPort(
|
||||||
|
name=f"Port {number}",
|
||||||
|
direction=SignalDirection.INPUT,
|
||||||
|
)
|
||||||
|
self._ports[port_id] = port
|
||||||
|
self._port_ids.append(port_id)
|
||||||
|
self._rebuild_list(port_id)
|
||||||
|
self.ports_changed.emit()
|
||||||
|
|
||||||
|
def _remove_port(self) -> None:
|
||||||
|
port_id = self._selected_port_id()
|
||||||
|
if port_id is None:
|
||||||
|
return
|
||||||
|
row = self._port_ids.index(port_id)
|
||||||
|
del self._ports[port_id]
|
||||||
|
self._port_ids.remove(port_id)
|
||||||
|
selected = (
|
||||||
|
self._port_ids[min(row, len(self._port_ids) - 1)]
|
||||||
|
if self._port_ids
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._rebuild_list(selected)
|
||||||
|
self.ports_changed.emit()
|
||||||
|
|
||||||
|
def _update_option_visibility(self) -> None:
|
||||||
|
signal = self.ui.typeSignal.isChecked()
|
||||||
|
self._set_layout_visible(self.ui.signalOptions, signal)
|
||||||
|
self._set_layout_visible(self.ui.bondOptions, not signal)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_layout_visible(layout: QLayout, visible: bool) -> None:
|
||||||
|
for index in range(layout.count()):
|
||||||
|
widget = layout.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(visible)
|
||||||
|
|
||||||
|
def _set_editor_enabled(self, enabled: bool) -> None:
|
||||||
|
for layout in (
|
||||||
|
self.ui.basicForm,
|
||||||
|
self.ui.signalOptions,
|
||||||
|
self.ui.bondOptions,
|
||||||
|
self.ui.descriptionForm,
|
||||||
|
):
|
||||||
|
for index in range(layout.count()):
|
||||||
|
widget = layout.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setEnabled(enabled)
|
||||||
115
tests/unit/test_port_editor.py
Normal file
115
tests/unit/test_port_editor.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bedit_core import models
|
||||||
|
from bedit_core.models import (
|
||||||
|
BondPort,
|
||||||
|
Component,
|
||||||
|
Interface,
|
||||||
|
PortID,
|
||||||
|
SignalDirection,
|
||||||
|
SignalPort,
|
||||||
|
ValueType,
|
||||||
|
)
|
||||||
|
from bedit_gui.documents import Document
|
||||||
|
from bedit_gui.views.port_editor_widget import PortEditorWidget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_port_editor_uses_a_detached_draft(qt_app: QApplication) -> None:
|
||||||
|
port_id = PortID("input")
|
||||||
|
original = {
|
||||||
|
port_id: SignalPort(
|
||||||
|
name="Input",
|
||||||
|
direction=SignalDirection.INPUT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editor = PortEditorWidget()
|
||||||
|
editor.set_ports(original)
|
||||||
|
|
||||||
|
editor.ui.nameEdit.setText("Changed")
|
||||||
|
editor.ui.nameEdit.textEdited.emit("Changed")
|
||||||
|
|
||||||
|
assert original[port_id].name == "Input"
|
||||||
|
assert editor.ports()[port_id].name == "Changed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_port_type_controls_option_visibility(qt_app: QApplication) -> None:
|
||||||
|
editor = PortEditorWidget()
|
||||||
|
editor.set_ports(
|
||||||
|
{
|
||||||
|
PortID("port"): SignalPort(
|
||||||
|
name="Port",
|
||||||
|
direction=SignalDirection.INPUT,
|
||||||
|
value_type=ValueType.REAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not editor.ui.signalTypeComboBox.isHidden()
|
||||||
|
assert editor.ui.domainComboBox.isHidden()
|
||||||
|
|
||||||
|
editor.ui.typeBond.setChecked(True)
|
||||||
|
|
||||||
|
assert editor.ui.signalTypeComboBox.isHidden()
|
||||||
|
assert not editor.ui.domainComboBox.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_bond_causality_is_loaded_and_changed(qt_app: QApplication) -> None:
|
||||||
|
port_id = PortID("bond")
|
||||||
|
editor = PortEditorWidget()
|
||||||
|
editor.set_ports({port_id: BondPort("Bond", SignalDirection.INPUT, causality_preference=models.PortCausality.PREFERRED_FLOW_OUT)})
|
||||||
|
|
||||||
|
assert editor.ui.causalityComboBox.currentData() is models.PortCausality.PREFERRED_FLOW_OUT
|
||||||
|
|
||||||
|
index = editor.ui.causalityComboBox.findData(models.PortCausality.FIXED_EFFORT_OUT)
|
||||||
|
editor.ui.causalityComboBox.setCurrentIndex(index)
|
||||||
|
|
||||||
|
assert editor.ports()[port_id].causality_preference is models.PortCausality.FIXED_EFFORT_OUT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_port_changes_are_one_undoable_operation(
|
||||||
|
qt_app: QApplication,
|
||||||
|
minimal_document,
|
||||||
|
) -> None:
|
||||||
|
component: Component = next(iter(minimal_document.root.values()))
|
||||||
|
removed_id = PortID("removed")
|
||||||
|
changed_id = PortID("changed")
|
||||||
|
added_id = PortID("added")
|
||||||
|
component.interface = Interface(
|
||||||
|
{
|
||||||
|
removed_id: SignalPort("Remove", SignalDirection.INPUT),
|
||||||
|
changed_id: SignalPort("Before", SignalDirection.INPUT),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
original = deepcopy(component.interface.ports)
|
||||||
|
updated = {
|
||||||
|
changed_id: BondPort(
|
||||||
|
"After",
|
||||||
|
SignalDirection.OUTPUT,
|
||||||
|
domain="electrical",
|
||||||
|
),
|
||||||
|
added_id: SignalPort("Added", SignalDirection.INPUT),
|
||||||
|
}
|
||||||
|
document = Document(qt_app)
|
||||||
|
|
||||||
|
document.update_component_ports(component, updated)
|
||||||
|
assert component.interface.ports == updated
|
||||||
|
|
||||||
|
document.undo_stack.undo()
|
||||||
|
assert component.interface.ports == original
|
||||||
|
|
||||||
|
document.undo_stack.redo()
|
||||||
|
assert component.interface.ports == updated
|
||||||
@@ -125,6 +125,20 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"domain": "power",
|
"domain": "power",
|
||||||
"causality_preference": "indifferent"
|
"causality_preference": "indifferent"
|
||||||
|
},
|
||||||
|
"d1f5aab2-7bff-4b1e-8697-98fb6c3d8f02": {
|
||||||
|
"port_type": "signal",
|
||||||
|
"name": "power",
|
||||||
|
"direction": "output",
|
||||||
|
"multiplicity": false,
|
||||||
|
"matrix_size": [
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"description": "Dissipated power",
|
||||||
|
"value_type": "real",
|
||||||
|
"quantity": null,
|
||||||
|
"unit": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -147,7 +161,8 @@
|
|||||||
"declarations": [],
|
"declarations": [],
|
||||||
"initial_equations": [],
|
"initial_equations": [],
|
||||||
"equations": [
|
"equations": [
|
||||||
"p.e = r*p.f;"
|
"p.e = r*p.f;",
|
||||||
|
"power = p.e*p.f;"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user