Compare commits
3 Commits
a96bde9642
...
8b45674cd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b45674cd2 | |||
| b417d85477 | |||
| d9304ba2e0 |
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)
|
||||
19
src/bedit_gui/commands/rename_component_command.py
Normal file
19
src/bedit_gui/commands/rename_component_command.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
from bedit_core.models import Component
|
||||
|
||||
class RenameComponentCommand(QUndoCommand):
|
||||
def __init__(self, document, component: Component, new_name: str) -> None:
|
||||
super().__init__("Rename component")
|
||||
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.old_name = component.name
|
||||
self.new_name = new_name
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.name = self.new_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.name = self.old_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
18
src/bedit_gui/commands/rename_document_command.py
Normal file
18
src/bedit_gui/commands/rename_document_command.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
|
||||
class RenameDocumentCommand(QUndoCommand):
|
||||
def __init__(self, document, new_name: str) -> None:
|
||||
super().__init__("Rename document")
|
||||
|
||||
self.document = document
|
||||
self.old_name = document.model.name
|
||||
self.new_name = new_name
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document.model.name = self.new_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document.model.name = self.old_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
@@ -1,27 +1,57 @@
|
||||
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_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.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):
|
||||
def __init__(
|
||||
self,
|
||||
document: Document,
|
||||
window: MainWindow,
|
||||
interface_editor_factory: InterfaceEditorFactory = InterfaceEditorDialog,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.document = document
|
||||
self.window = window
|
||||
self.model = DocumentTreeModel()
|
||||
self.interface_editor_factory = interface_editor_factory
|
||||
|
||||
window.ui.documentTree.setModel(self.model)
|
||||
document.model_changed.connect(self._on_document_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.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.CustomContextMenu
|
||||
)
|
||||
window.ui.documentTree.customContextMenuRequested.connect(
|
||||
self._show_context_menu
|
||||
)
|
||||
|
||||
self._on_document_changed(document.model)
|
||||
|
||||
@@ -32,3 +62,25 @@ class DocumentTreeController(QObject):
|
||||
# Optional presentation behavior. Later, you could instead remember
|
||||
# expanded component IDs and restore only those nodes.
|
||||
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,8 +5,11 @@ from pathlib import Path
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
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
|
||||
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
||||
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
|
||||
|
||||
|
||||
@@ -78,3 +81,37 @@ class Document(QObject):
|
||||
name="Untitled",
|
||||
root={},
|
||||
)
|
||||
|
||||
def rename(self, name: str) -> None:
|
||||
self.undo_stack.push(RenameDocumentCommand(self, name))
|
||||
|
||||
def rename_component(self, component: Component, name: str) -> None:
|
||||
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()
|
||||
|
||||
260
src/bedit_gui/ui/forms/port_editor_widget.ui
Normal file
260
src/bedit_gui/ui/forms/port_editor_widget.ui
Normal file
@@ -0,0 +1,260 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>PortEditor</class>
|
||||
<widget class="QWidget" name="PortEditor">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>541</width>
|
||||
<height>420</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="leftColumn">
|
||||
<item>
|
||||
<widget class="QListView" name="portList"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttonRow">
|
||||
<item>
|
||||
<widget class="QPushButton" name="addPort">
|
||||
<property name="text">
|
||||
<string>Add Port</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="removePort">
|
||||
<property name="text">
|
||||
<string>Remove Port</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="rightColumn">
|
||||
<item>
|
||||
<layout class="QFormLayout" name="basicForm">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="nameLabel">
|
||||
<property name="text">
|
||||
<string>Name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="nameEdit"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="typeLabel">
|
||||
<property name="text">
|
||||
<string>Type:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<layout class="QHBoxLayout" name="typeRow">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="typeSignal">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="typeBond">
|
||||
<property name="text">
|
||||
<string>Power Bond</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="orientationLabel">
|
||||
<property name="text">
|
||||
<string>Orientation:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<layout class="QHBoxLayout" name="orientationRow">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="inputOrientation">
|
||||
<property name="text">
|
||||
<string>Input</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="outputOrientation">
|
||||
<property name="text">
|
||||
<string>Output</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="sizeLabel">
|
||||
<property name="text">
|
||||
<string>Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<layout class="QHBoxLayout" name="sizeRow">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="widthSize">
|
||||
<property name="suffix">
|
||||
<string> rows</string>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="heightSize">
|
||||
<property name="suffix">
|
||||
<string> columns</string>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="domainLabel">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QCheckBox" name="multiplicityCheckBox">
|
||||
<property name="text">
|
||||
<string>Allow multiple connections</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="signalOptions">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="signalTypeLabel">
|
||||
<property name="text">
|
||||
<string>Signal Type:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="signalTypeComboBox"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="bondOptions">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="domainLabel_2">
|
||||
<property name="text">
|
||||
<string>Domain:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="domainComboBox"/>
|
||||
</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>
|
||||
</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>
|
||||
<widget class="Line" name="line_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="descriptionForm">
|
||||
<property name="fieldGrowthPolicy">
|
||||
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="descriptionLabel">
|
||||
<property name="text">
|
||||
<string>Description:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPlainTextEdit" name="descriptionEdit">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
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()
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt
|
||||
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
|
||||
@@ -16,6 +17,9 @@ class DocumentTreeNode:
|
||||
|
||||
|
||||
class DocumentTreeModel(QAbstractItemModel):
|
||||
rename_document_requested = Signal(str)
|
||||
rename_component_requested = Signal(Component, str)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._document: CoreDocument | None = None
|
||||
@@ -33,18 +37,9 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
||||
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)
|
||||
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 self.createIndex(row, column, parent_node.children[row])
|
||||
|
||||
@@ -66,22 +61,38 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
row = grandparent.children.index(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():
|
||||
return None
|
||||
|
||||
node = index.internalPointer()
|
||||
if (
|
||||
role == Qt.ItemDataRole.DisplayRole
|
||||
and isinstance(node, DocumentTreeNode)
|
||||
):
|
||||
if not isinstance(node, DocumentTreeNode):
|
||||
return None
|
||||
|
||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||
return node.name
|
||||
|
||||
return None
|
||||
|
||||
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool:
|
||||
if role != Qt.ItemDataRole.EditRole or not index.isValid():
|
||||
return False
|
||||
|
||||
node = index.internalPointer()
|
||||
if not isinstance(node, DocumentTreeNode) or not isinstance(node.value, (CoreDocument, Component)):
|
||||
return False
|
||||
|
||||
name = str(value).strip()
|
||||
|
||||
if not name or name == node.name:
|
||||
return False
|
||||
|
||||
if isinstance(node.value, CoreDocument):
|
||||
self.rename_document_requested.emit(name)
|
||||
elif isinstance(node.value, Component):
|
||||
self.rename_component_requested.emit(node.value, name)
|
||||
return True
|
||||
|
||||
def _node(self, index: QModelIndex | None) -> DocumentTreeNode:
|
||||
if index is None or not index.isValid():
|
||||
return self._root
|
||||
@@ -89,6 +100,26 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
node = index.internalPointer()
|
||||
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:
|
||||
flags = super().flags(index)
|
||||
|
||||
if not index.isValid():
|
||||
return flags
|
||||
|
||||
node = index.internalPointer()
|
||||
|
||||
# Make the document root node editable
|
||||
if isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
||||
flags |= Qt.ItemFlag.ItemIsEditable
|
||||
|
||||
return flags
|
||||
|
||||
def _build_tree(self, document: CoreDocument) -> DocumentTreeNode:
|
||||
# QT's invisible root
|
||||
root = DocumentTreeNode(
|
||||
@@ -98,26 +129,17 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
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, parent=root, children=[])
|
||||
root.children.append(document_root)
|
||||
|
||||
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
|
||||
for component_id, component in components.items():
|
||||
component_node = DocumentTreeNode(
|
||||
name = component.name,
|
||||
value = (component_id, component),
|
||||
parent = root,
|
||||
children = []
|
||||
)
|
||||
for component in components.values():
|
||||
component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[])
|
||||
root.children.append(component_node)
|
||||
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
_list_children(component_node, component.implementation.graph.components)
|
||||
|
||||
_list_children(document_root, document.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
|
||||
@@ -2,10 +2,10 @@
|
||||
"file_format_version": 1,
|
||||
"format_version": 1,
|
||||
"id": "3b6780c7-488b-471e-a784-392db7632090",
|
||||
"name": "Current Document",
|
||||
"name": "main_node",
|
||||
"root": {
|
||||
"50e6ef97-f686-4400-bc01-e5a352e8cc22": {
|
||||
"name": "test",
|
||||
"name": "some_bondgraph",
|
||||
"interface": {
|
||||
"ports": {}
|
||||
},
|
||||
@@ -125,6 +125,20 @@
|
||||
"description": "",
|
||||
"domain": "power",
|
||||
"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": [],
|
||||
"initial_equations": [],
|
||||
"equations": [
|
||||
"p.e = r*p.f;"
|
||||
"p.e = r*p.f;",
|
||||
"power = p.e*p.f;"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user