Compare commits

..

2 Commits

Author SHA1 Message Date
76bd313f79 Basic document handling 2026-07-19 21:26:27 +02:00
c5ab3329ae Start of BEdit 2026-07-19 19:19:15 +02:00
47 changed files with 7292 additions and 0 deletions

10
BEdit/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.pytest_cache/
.idea/
.vscode/*
!.vscode/tasks.json

136
BEdit/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,136 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Qt: Open Main Window in Designer",
"type": "shell",
"command": "pyside6-designer",
"args": [
"${workspaceFolder}/ui/main_window.ui"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"QT_QPA_PLATFORMTHEME" :"qt6ct",
"QT_QPA_PLATFORM": "xcb"
}
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated"
}
},
{
"label": "Qt: Open Settings Dialog in Designer",
"type": "shell",
"command": "pyside6-designer",
"args": [
"${workspaceFolder}/ui/settings_dialog.ui"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_QPA_PLATFORM": "xcb"
}
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated"
}
},
{
"label": "Qt: Compile Resources to Python",
"type": "shell",
"command": "pyside6-rcc",
"args": [
"${workspaceFolder}/resources/resources.qrc",
"-o",
"${workspaceFolder}/src/bedit/resources_rc.py"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "shared",
"clear": true
}
},
{
"label": "Qt: Compile UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": [
"--from-imports",
"${workspaceFolder}/ui/main_window.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_main_window.py"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "shared"
}
},
{
"label": "Qt: Compile Settings UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": [
"--from-imports",
"${workspaceFolder}/ui/settings_dialog.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_settings_dialog.py"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "shared"
}
},
{
"label": "Qt: Compile Component Options UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": [
"--from-imports",
"${workspaceFolder}/ui/component_options_dialog.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_component_options_dialog.py"
],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": [],
"presentation": {"reveal": "silent", "panel": "shared"}
},
{
"label": "Qt: Build Designer Files",
"dependsOrder": "sequence",
"dependsOn": [
"Qt: Compile Resources to Python",
"Qt: Compile UI to Python",
"Qt: Compile Settings UI to Python",
"Qt: Compile Component Options UI to Python"
],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "silent",
"panel": "shared",
"clear": true
}
}
]
}

177
BEdit/README.md Normal file
View File

@@ -0,0 +1,177 @@
# BEdit Qt starter
A small desktop application scaffold using Python and PySide6 (the official Qt
bindings). Its interface is maintained in Qt Designer and it includes a main
window, menu bar, blank central workspace, common keyboard shortcuts, and
persisted window geometry. The application forces Qt's light Fusion palette, so
it remains light even when the operating-system theme is dark.
## Run it
Python 3.10 or newer is required. From this directory:
```bash
python -m venv .venv
```
Activate the environment:
- Windows PowerShell: `.venv\Scripts\Activate.ps1`
- Windows Command Prompt: `.venv\Scripts\activate.bat`
- Linux/macOS: `source .venv/bin/activate`
Then install and launch:
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
python -m bedit
```
After installation, the `bedit` command also launches the application.
## Project layout
```text
.
├── pyproject.toml dependencies, package metadata, and `bedit` command
├── README.md
├── ui/main_window.ui editable Qt Designer source
└── src/bedit
├── __main__.py supports `python -m bedit`
├── app.py starts Qt and applies the forced light palette
├── main_window.py behavior and signal connections
└── ui_main_window.py generated from the Designer file; do not hand-edit
```
## Designing the UI further
The project now uses Qt Designer. It is included with PySide6 on most
installations. Open the existing form with:
```bash
pyside6-designer ui/main_window.ui
```
In Designer, use the Widget Box to add controls, the Object Inspector to select
them, and the Property Editor to name and configure them. Always put widgets in
a layout (horizontal, vertical, grid, or form) so the window resizes correctly.
Save the form, close the running application if necessary, then regenerate its
Python wrapper:
```bash
pyside6-uic ui/main_window.ui -o src/bedit/ui_main_window.py
```
Do not hand-edit the generated Python file; change the `.ui` file and regenerate
it. Add behavior and signal connections in `main_window.py`. Widget names from
Designer are available there through `self.ui`, such as `self.ui.graphView`.
In VS Code, the same commands are available through **Terminal → Run Task**:
- **Qt: Open Main Window in Designer** opens the form for visual editing.
- **Qt: Build Designer Files** compiles resources and then regenerates the UI
wrapper. It is the default build task, available with `Ctrl+Shift+B`.
- The separate resource and UI compilation tasks remain available when only one
generated file needs rebuilding.
## A sensible next design pass
1. Sketch the main tasks and screens before choosing widgets.
2. Turn each major area into its own widget class in `src/bedit/widgets/`.
3. Use a `QStackedWidget` for page-like navigation, or `QDockWidget` for movable
tool panels in an editor-style application.
4. Use reusable `QAction` objects for menu commands and any future toolbars.
5. Keep file/data operations outside widget classes as the application grows.
6. Add icons through a Qt resource file (`.qrc`) so packaging is reliable.
7. Test on Windows regularly; fonts, scaling, and native dialogs vary by platform.
## Graph and library prototype
Documents and libraries use the same recursive format: a library is simply a
BEdit document used as a copy source. The built-in example defines A, B, and C.
- A document can own multiple independent top-level graph or text components.
Right-click **Current Document** to create one, and double-click a current
component in the tree to activate it.
- Drag a component from Libraries onto the workspace. Placement recursively
copies it with new IDs, leaving no link to the source.
- Drag components to move them; movement participates in undo and redo.
- Components, interface terminals, and connections are selectable. Use a rubber
band or Ctrl-click for multiple selection, Delete to remove items, and the
standard Cut/Copy/Paste shortcuts to duplicate selected component groups.
- Click an output port and then an input port to create a connection.
- Double-click a graph component to open its owned subgraph; use **Up** to return.
- Graph components show **Pointer**, **Input**, and **Output** tools. Select an
interface tool and click the canvas to add a visible internal terminal and a
corresponding external block port. Interface terminals can be moved afterward.
- Right-click a component on the canvas or in Current Document to edit its name,
icon shape, icon text, fill color, and border color. The same dialog can hide
that component's contained subtree from the Libraries tree.
- Double-click a text component to edit its input list, output list, and
`implementation.source` JSON.
- Right-click any graph component under Current Document to add nested graph or
text blocks. Any current-document component can also be deleted there.
- The active document hierarchy has its own Document panel; the Libraries panel
contains only configured external libraries.
- Select one or more blocks and press `Ctrl+R`, or use the Transform toolbar, to
rotate them clockwise by 90 degrees. Rotation is saved and supports undo/redo.
**Apply JSON** updates that source and participates in undo/redo.
- File → Save writes the complete recursive document to JSON.
- File → Close Document removes the active document and returns to an empty
workspace. An open graph uses a light gray, 32-unit dotted canvas.
- Edit → Settings → Libraries accepts document files or folders of JSON files.
Every component owns its ports, declarative icon, properties, and child graph:
```json
{
"format": "bedit-document",
"version": 1,
"roots": [{
"id": "my-component",
"name": "My Component",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input"}],
"outputs": [{"id": "out", "name": "Output"}]
},
"icon": {
"shape": "rectangle",
"fill": "#dbeafe",
"border": "#245c9c",
"text": "Component"
},
"properties": {},
"implementation": {
"kind": "text",
"source": {
"equations": ["out = gain * in"],
"parameters": {"gain": 1.0}
}
}
}]
}
```
Graph components use `"implementation": {"kind": "graph", "graph": ...}`;
text components use `"implementation": {"kind": "text", "source": ...}` and
never own a graph. Supported icon shapes are currently `rectangle` and
`ellipse`. The recursive model is under `src/bedit/document/`, library loading
and the live Current Document tree are under `src/bedit/library/`, and graphics
are isolated under `src/bedit/workspace/`.
## Optional tools
You do not need another GUI framework. Useful additions are:
- **Qt Designer** for drag-and-drop form layout.
- **Ruff** for formatting/linting (`ruff check .`).
- **pytest-qt** later for GUI interaction tests.
- **PyInstaller** or **Nuitka** later to produce a Windows `.exe`.
The light colors are defined in `apply_light_theme()` in `src/bedit/app.py`.
Adjust that palette if you want different light colors. Avoid a large stylesheet
unless the application needs highly customized controls; palettes preserve more
of Qt's standard behavior.

32
BEdit/pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "bedit"
version = "0.1.0"
description = "A starter Qt desktop application"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"PySide6>=6.7,<7",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"ruff>=0.5",
]
[project.gui-scripts]
bedit = "bedit.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
bedit = ["data/libraries/*.json"]
[tool.ruff]
line-length = 100
target-version = "py310"

View File

@@ -0,0 +1,24 @@
Oxygen Icon Theme has been developed by The Oxygen Team.
Art Directors:
Nuno F. Pinheiro <nuno@nuno-icons.com>
David Vignoni <david@oxygen-icons.org>
Naming Coordinator
Jakob Petsovits <jpetso@gmx.at>
Designers:
David J. Miller <miller@oxygen-icons.org>
David Vignoni <david@oxygen-icons.org>
Johann Ollivier Lapeyre <johann@oxygen-icons.org>
Kenneth Wimer <ken@oxygen-icons.org>
Nuno F. Pinheiro <nuno@nuno-icons.com>
Riccardo Iaconelli <riccardo@oxygen-icons.org>
David J. Miller <miller@oxygen-icons.org>
Thanks to:
Lee Olson: Contributed drawing used in application-x-bittorent icon.
Marco Aurélio "Coré": Improved audio-input-microphone icon.
Matthias Kretz: Contributed "audio-input-line" device icon.
Mauricio Piacentini <piacentini@kde.org> : game icons mashup
Erlend Hamberg: "text-x-haskell" mimetype icon.

View File

@@ -0,0 +1,216 @@
The Oxygen Icon Theme
Copyright (C) 2007 Nuno Pinheiro <nuno@oxygen-icons.org>
Copyright (C) 2007 David Vignoni <david@icon-king.com>
Copyright (C) 2007 David Miller <miller@oxygen-icons.org>
Copyright (C) 2007 Johann Ollivier Lapeyre <johann@oxygen-icons.org>
Copyright (C) 2007 Kenneth Wimer <kwwii@bootsplash.org>
Copyright (C) 2007 Riccardo Iaconelli <riccardo@oxygen-icons.org>
and others
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library. If not, see <http://www.gnu.org/licenses/>.
Clarification:
The GNU Lesser General Public License or LGPL is written for
software libraries in the first place. We expressly want the LGPL to
be valid for this artwork library too.
KDE Oxygen theme icons is a special kind of software library, it is an
artwork library, it's elements can be used in a Graphical User Interface, or
GUI.
Source code, for this library means:
- where they exist, SVG;
- otherwise, if applicable, the multi-layered formats xcf or psd, or
otherwise png.
The LGPL in some sections obliges you to make the files carry
notices. With images this is in some cases impossible or hardly useful.
With this library a notice is placed at a prominent place in the directory
containing the elements. You may follow this practice.
The exception in section 5 of the GNU Lesser General Public License covers
the use of elements of this art library in a GUI.
kde-artists [at] kde.org
-----
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -0,0 +1 @@
Oxygen Icons is a freedesktop.org compatible icon theme originally developed for the KDE Plasma desktop environment in combination with the Oxygen Style. It features smooth gradients, soft shadows, and a slightly glossy look.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,14 @@
<RCC>
<qresource prefix="icons">
<file>icons/edit-paste.png</file>
<file>icons/edit-redo.png</file>
<file>icons/edit-cut.png</file>
<file>icons/edit-copy.png</file>
<file>icons/edit-undo.png</file>
<file>icons/document-save.png</file>
<file>icons/document-save-as.png</file>
<file>icons/document-open.png</file>
<file>icons/document-new.png</file>
<file>icons/transform-rotate.png</file>
</qresource>
</RCC>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
"""BEdit desktop application."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,6 @@
from bedit.app import main
if __name__ == "__main__":
raise SystemExit(main())

51
BEdit/src/bedit/app.py Normal file
View File

@@ -0,0 +1,51 @@
import sys
from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication, QStyleFactory
from bedit.main_window import MainWindow
def apply_light_theme(app: QApplication) -> None:
"""Use a predictable light Qt theme, independent of the desktop theme."""
app.setStyle(QStyleFactory.create("Fusion"))
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(240, 240, 240))
palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(233, 233, 233))
palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240))
palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
palette.setColor(QPalette.ColorRole.Link, QColor(0, 102, 204))
palette.setColor(QPalette.ColorRole.Highlight, QColor(0, 120, 215))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.white)
palette.setColor(
QPalette.ColorGroup.Disabled,
QPalette.ColorRole.Text,
QColor(109, 109, 109),
)
palette.setColor(
QPalette.ColorGroup.Disabled,
QPalette.ColorRole.ButtonText,
QColor(109, 109, 109),
)
app.setPalette(palette)
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("BEdit")
app.setApplicationDisplayName("BEdit")
app.setOrganizationName("BEdit")
QCoreApplication.setApplicationVersion("0.1.0")
apply_light_theme(app)
window = MainWindow()
window.show()
return app.exec()

View File

@@ -0,0 +1,31 @@
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QDialog, QMessageBox
from bedit.document.model import Component
from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog
class ComponentOptionsDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_ComponentOptionsDialog()
self.ui.setupUi(self)
self.ui.nameEdit.setText(component.name)
self.ui.shapeCombo.setCurrentText(component.icon.shape)
self.ui.iconTextEdit.setText(component.icon.text)
self.ui.fillEdit.setText(component.icon.fill)
self.ui.borderEdit.setText(component.icon.border)
self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library)
def accept(self) -> None:
if not self.ui.nameEdit.text().strip():
QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.")
return
for label, value in (
("fill", self.ui.fillEdit.text()),
("border", self.ui.borderEdit.text()),
):
if not QColor(value).isValid():
QMessageBox.warning(self, "Invalid color", f"The {label} color is not valid.")
return
super().accept()

View File

@@ -0,0 +1,52 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {"name": "Example Library"},
"roots": [
{
"id": "example-a",
"name": "Block A",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "rectangle", "fill": "#dbeafe", "border": "#245c9c", "text": "A"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {
"kind": "text",
"source": {"equations": ["out = gain * in"], "parameters": {"gain": 1.0}}
}
},
{
"id": "example-b",
"name": "Block B",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "ellipse", "fill": "#dcfce7", "border": "#277342", "text": "B"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {
"kind": "text",
"source": {"equations": ["out = in + offset"], "parameters": {"offset": 0.0}}
}
},
{
"id": "example-c",
"name": "Block C",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}],
"outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}]
},
"icon": {"shape": "rectangle", "fill": "#fef3c7", "border": "#8a641c", "text": "C"},
"properties": {},
"library": {"showSubtree": true},
"implementation": {"kind": "graph", "graph": {"blocks": [], "connections": []}}
}
]
}

View File

@@ -0,0 +1,351 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {
"name": "Untitled"
},
"roots": [
{
"id": "97cd3d0b-c36a-467e-b1f5-4c794979bd99",
"name": "something",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "in",
"name": "Input",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {}
}
],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Something"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": [],
"parameters": {}
}
}
},
{
"id": "614b4018-c00e-40ce-b59c-5d5795eab7d0",
"name": "Test",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Test"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [
{
"id": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"name": "sin",
"position": {
"x": -28.0,
"y": -179.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "input-0bcb214f",
"name": "a",
"position": {
"x": -223.0,
"y": -161.0
},
"properties": {}
}
],
"outputs": [
{
"id": "output-a55cd289",
"name": "b",
"position": {
"x": 133.0,
"y": -152.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "sin()"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": [
{
"id": "cef30af9-fdc5-4a28-a7d6-4ab3c83e65d7",
"source": {
"interface": "input-0bcb214f"
},
"target": {
"interface": "output-a55cd289"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "88f40d88-f62b-432f-a6a0-95513b48409e",
"name": "constant",
"position": {
"x": -260.0,
"y": -178.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": [
{
"id": "output-1e15ff3f",
"name": "Output 1",
"position": {
"x": 71.0,
"y": -134.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "C"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
}
}
},
{
"id": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5",
"name": "New Text Block 1",
"position": {
"x": 225.0,
"y": -167.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "in",
"name": "Input",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {}
}
],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": [],
"parameters": {}
}
}
}
],
"connections": [
{
"id": "b3837790-26ba-46a6-91f9-3b6808c7ad1a",
"source": {
"block": "88f40d88-f62b-432f-a6a0-95513b48409e",
"port": "output-1e15ff3f"
},
"target": {
"block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"port": "input-0bcb214f"
},
"name": "",
"properties": {}
},
{
"id": "50b2c7aa-d824-407c-ae0f-6b3bb4b1b79e",
"source": {
"block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce",
"port": "output-a55cd289"
},
"target": {
"block": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5",
"port": "in"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "465796f5-9075-45f6-81b9-7fa17476392c",
"name": "sin",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "input-0bcb214f",
"name": "Input 1",
"position": {
"x": -223.0,
"y": -161.0
},
"properties": {}
}
],
"outputs": [
{
"id": "output-a55cd289",
"name": "Output 1",
"position": {
"x": 133.0,
"y": -152.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "sin()"
},
"properties": {},
"library": {
"showSubtree": false
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": [
{
"id": "029ccfaa-7150-4a53-beec-286ff8e2a628",
"source": {
"interface": "input-0bcb214f"
},
"target": {
"interface": "output-a55cd289"
},
"name": "",
"properties": {}
}
]
}
}
},
{
"id": "bb0adff3-baf2-469b-9a80-f05910d0f259",
"name": "constant",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": [
{
"id": "output-1e15ff3f",
"name": "Output 1",
"position": {
"x": 71.0,
"y": -134.0
},
"properties": {}
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "C"
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
}
}
}
]
}

View File

@@ -0,0 +1,13 @@
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Graph, GraphDocument, Icon, Port
__all__ = [
"Component",
"Connection",
"DocumentController",
"Endpoint",
"Graph",
"GraphDocument",
"Icon",
"Port",
]

View File

@@ -0,0 +1,262 @@
from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand
from bedit.document.model import Component, Connection, Port
class AddComponentCommand(QUndoCommand):
def __init__(self, controller, owner_id: str | None, component: Component) -> None:
super().__init__(f"Add {component.name}")
self.controller = controller
self.owner_id = owner_id
self.component = component
def redo(self) -> None:
self.controller._insert_component(self.owner_id, self.component)
def undo(self) -> None:
self.controller._remove_component(self.owner_id, self.component.id)
class MoveComponentCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
component_id: str,
old: QPointF,
new: QPointF,
) -> None:
super().__init__("Move component")
self.controller = controller
self.owner_id = owner_id
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._move_component(self.owner_id, self.component_id, self.new)
def undo(self) -> None:
self.controller._move_component(self.owner_id, self.component_id, self.old)
class RotateComponentsCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
rotations: dict[str, tuple[float, float]],
) -> None:
super().__init__("Rotate components")
self.controller = controller
self.owner_id = owner_id
self.rotations = rotations
def redo(self) -> None:
for component_id, (_old, new) in self.rotations.items():
self.controller._rotate_component(self.owner_id, component_id, new)
def undo(self) -> None:
for component_id, (old, _new) in self.rotations.items():
self.controller._rotate_component(self.owner_id, component_id, old)
class AddConnectionCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, connection: Connection) -> None:
super().__init__("Connect components")
self.controller = controller
self.owner_id = owner_id
self.connection = connection
def redo(self) -> None:
self.controller._insert_connection(self.owner_id, self.connection)
def undo(self) -> None:
self.controller._remove_connection(self.owner_id, self.connection.id)
class ReplaceComponentCommand(QUndoCommand):
def __init__(self, controller, old: Component, new: Component) -> None:
super().__init__("Apply JSON changes")
self.controller = controller
self.old = old
self.new = new
def redo(self) -> None:
self.controller._replace_component(self.old.id, self.new)
def undo(self) -> None:
self.controller._replace_component(self.new.id, self.old)
class AddInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, direction: str, port: Port) -> None:
super().__init__(f"Add {direction}")
self.controller = controller
self.owner_id = owner_id
self.direction = direction
self.port = port
def redo(self) -> None:
self.controller._insert_interface_port(self.owner_id, self.direction, self.port)
def undo(self) -> None:
self.controller._remove_interface_port(self.owner_id, self.direction, self.port.id)
class ReplaceSourceCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Apply source JSON")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._replace_source(self.component_id, self.new)
def undo(self) -> None:
self.controller._replace_source(self.component_id, self.old)
class MoveInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, port_id: str, old: QPointF, new: QPointF) -> None:
super().__init__("Move interface terminal")
self.controller = controller
self.owner_id = owner_id
self.port_id = port_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._move_interface_port(self.owner_id, self.port_id, self.new)
def undo(self) -> None:
self.controller._move_interface_port(self.owner_id, self.port_id, self.old)
class EditComponentAppearanceCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Edit component appearance")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._set_component_appearance(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_component_appearance(self.component_id, self.old)
class RenameInterfacePortCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, port_id: str, old: str, new: str) -> None:
super().__init__("Rename interface port")
self.controller, self.owner_id, self.port_id = controller, owner_id, port_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._rename_interface_port(self.owner_id, self.port_id, self.new)
def undo(self) -> None:
self.controller._rename_interface_port(self.owner_id, self.port_id, self.old)
class RenameConnectionCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, connection_id: str, old: str, new: str) -> None:
super().__init__("Rename connection")
self.controller, self.owner_id, self.connection_id = controller, owner_id, connection_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._rename_connection(self.owner_id, self.connection_id, self.new)
def undo(self) -> None:
self.controller._rename_connection(self.owner_id, self.connection_id, self.old)
class DeleteSelectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
) -> None:
super().__init__("Delete selection")
self.controller = controller
self.owner_id = owner_id
self.blocks = blocks
self.connections = connections
self.inputs = inputs
self.outputs = outputs
def redo(self) -> None:
self.controller._delete_items(
self.owner_id,
set(self.blocks),
set(self.connections),
{port.id for port in self.inputs},
{port.id for port in self.outputs},
)
def undo(self) -> None:
self.controller._restore_items(
self.owner_id,
self.blocks,
self.connections,
self.inputs,
self.outputs,
)
class PasteSelectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
blocks: dict[str, Component],
connections: dict[str, Connection],
) -> None:
super().__init__("Paste selection")
self.controller = controller
self.owner_id = owner_id
self.blocks = blocks
self.connections = connections
def redo(self) -> None:
self.controller._restore_items(
self.owner_id,
self.blocks,
self.connections,
[],
[],
)
def undo(self) -> None:
self.controller._delete_items(
self.owner_id,
set(self.blocks),
set(self.connections),
set(),
set(),
)
class EditTextDefinitionCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict) -> None:
super().__init__("Edit text component")
self.controller = controller
self.component_id = component_id
self.old = old
self.new = new
def redo(self) -> None:
self.controller._set_text_definition(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_text_definition(self.component_id, self.old)

View File

@@ -0,0 +1,673 @@
from copy import deepcopy
from pathlib import Path
from uuid import uuid4
from PySide6.QtCore import QObject, QPointF, Signal
from PySide6.QtGui import QUndoStack
from bedit.document.commands import (
AddComponentCommand,
AddConnectionCommand,
AddInterfacePortCommand,
DeleteSelectionCommand,
EditTextDefinitionCommand,
EditComponentAppearanceCommand,
MoveComponentCommand,
MoveInterfacePortCommand,
PasteSelectionCommand,
RenameConnectionCommand,
RenameInterfacePortCommand,
ReplaceSourceCommand,
RotateComponentsCommand,
)
from bedit.document.model import (
Component,
Connection,
Endpoint,
GraphDocument,
Icon,
Port,
clone_component,
)
from bedit.document.serializer import JsonDocumentSerializer
class DocumentController(QObject):
documentReset = Signal()
documentOpenedChanged = Signal(bool)
activeGraphChanged = Signal()
componentAdded = Signal(str)
componentRemoved = Signal(str)
componentMoved = Signal(str, QPointF)
componentRotated = Signal(str, float)
connectionAdded = Signal(str)
connectionRemoved = Signal(str)
interfaceChanged = Signal()
filePathChanged = Signal(object)
modifiedChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.document: GraphDocument | None = None
self.active_component_id: str | None = None
self.file_path: Path | None = None
self.undo_stack = QUndoStack(self)
self.undo_stack.cleanChanged.connect(self._clean_changed)
def _clean_changed(self, clean: bool) -> None:
self.modifiedChanged.emit(not clean)
@property
def active_component(self) -> Component | None:
if self.document is None or self.active_component_id is None:
return None
return self.document.find_component(self.active_component_id)
@property
def active_graph(self):
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("There is no active graph")
return component.graph
def new_document(self) -> None:
self.document = GraphDocument.empty()
self.active_component_id = None
self.file_path = None
self.undo_stack.clear()
self.documentOpenedChanged.emit(True)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(None)
def close_document(self) -> None:
self.document = None
self.active_component_id = None
self.file_path = None
self.undo_stack.clear()
self.documentOpenedChanged.emit(False)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(None)
def load(self, path: Path) -> None:
self.document = JsonDocumentSerializer.load(path)
self.active_component_id = next(iter(self.document.roots), None)
self.file_path = path
self.undo_stack.clear()
self.undo_stack.setClean()
self.documentOpenedChanged.emit(True)
self.documentReset.emit()
self.activeGraphChanged.emit()
self.filePathChanged.emit(path)
def save(self, path: Path | None = None) -> Path:
if self.document is None:
raise ValueError("There is no open document")
target = path or self.file_path
if target is None:
raise ValueError("No file path has been selected")
JsonDocumentSerializer.save(self.document, target)
self.file_path = target
self.undo_stack.setClean()
self.filePathChanged.emit(target)
return target
def activate_component(self, component_id: str) -> None:
if self.document is None or self.document.find_component(component_id) is None:
return
self.active_component_id = component_id
self.activeGraphChanged.emit()
def navigate_up(self) -> None:
if self.document is None or self.active_component_id is None:
return
parent = self.document.find_parent(self.active_component_id)
if parent is not None:
self.activate_component(parent.id)
def breadcrumb(self) -> list[str]:
if self.document is None or self.active_component is None:
return []
names = [self.active_component.name]
current = self.active_component
while True:
parent = self.document.find_parent(current.id)
if parent is None:
break
names.append(parent.name)
current = parent
return list(reversed(names))
def add_root(self, kind: str) -> str:
if self.document is None:
raise ValueError("Open or create a document first")
number = len(self.document.roots) + 1
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, None, component))
self.activate_component(component.id)
return component.id
def add_child(self, owner_id: str, kind: str) -> str:
if self.document is None:
raise ValueError("Open or create a document first")
owner = self.document.find_component(owner_id)
if owner is None or owner.implementation_kind != "graph":
raise ValueError("Children can only be added to graph components")
number = len(owner.graph.blocks) + 1
component = Component(
id=str(uuid4()),
name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}",
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": [], "parameters": {}} if kind == "text" else {},
)
self.undo_stack.push(AddComponentCommand(self, owner_id, component))
return component.id
def delete_component(self, component_id: str) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
parent = self.document.find_parent(component_id)
owner_id = parent.id if parent else None
connections = {}
if parent is not None:
connections = {
connection.id: connection
for connection in parent.graph.connections.values()
if component_id in (connection.source.block, connection.target.block)
}
self.undo_stack.push(
DeleteSelectionCommand(
self,
owner_id,
{component_id: component},
connections,
[],
[],
)
)
def add_component_copy(self, source: Component, position: QPointF) -> str:
if self.active_component is None or self.active_component.implementation_kind != "graph":
raise ValueError("Open a graph component before placing components")
component = clone_component(source)
component.x, component.y = position.x(), position.y()
self.undo_stack.push(AddComponentCommand(self, self.active_component_id, component))
return component.id
def move_component(self, component_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
MoveComponentCommand(self, self.active_component_id, component_id, old, new)
)
def rotate_components(self, component_ids: set[str]) -> None:
if self.active_component is None or self.active_component_id is None:
return
rotations = {
component_id: (component.rotation, (component.rotation + 90.0) % 360.0)
for component_id in component_ids
if (component := self.active_component.graph.blocks.get(component_id)) is not None
}
if rotations:
self.undo_stack.push(
RotateComponentsCommand(self, self.active_component_id, rotations)
)
def connect(self, source: Endpoint, target: Endpoint) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
connection = Connection(str(uuid4()), source, target)
self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection)
)
return connection.id
def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before adding an interface")
ports = component.inputs if direction == "input" else component.outputs
port = Port(
id=f"{direction}-{uuid4().hex[:8]}",
name=f"{direction.title()} {len(ports) + 1}",
x=position.x(),
y=position.y(),
)
self.undo_stack.push(
AddInterfacePortCommand(self, component.id, direction, port)
)
return port.id
def move_interface_port(self, port_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
MoveInterfacePortCommand(self, self.active_component_id, port_id, old, new)
)
def rename_interface_port(self, port_id: str, name: str) -> None:
owner = self.active_component
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None and port.name != name:
self.undo_stack.push(
RenameInterfacePortCommand(self, owner.id, port_id, port.name, name)
)
def rename_connection(self, connection_id: str, name: str) -> None:
owner = self.active_component
if owner is None or owner.implementation_kind != "graph":
return
connection = owner.graph.connections.get(connection_id)
if connection is not None and connection.name != name:
self.undo_stack.push(
RenameConnectionCommand(self, owner.id, connection_id, connection.name, name)
)
def replace_active_source(self, source: dict) -> None:
component = self.active_component
if component is None or component.implementation_kind != "text":
raise ValueError("Only text-defined components have source JSON")
self.undo_stack.push(
ReplaceSourceCommand(
self,
component.id,
deepcopy(component.source),
deepcopy(source),
)
)
def replace_active_text_definition(
self,
inputs: list[Port],
outputs: list[Port],
source: dict,
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "text":
raise ValueError("Only text-defined components can be edited here")
input_ids = [port.id for port in inputs]
output_ids = [port.id for port in outputs]
if len(set(input_ids)) != len(input_ids) or len(set(output_ids)) != len(output_ids):
raise ValueError("Input and output IDs must be unique")
if self.document is not None:
parent = self.document.find_parent(component.id)
if parent is not None:
for connection in parent.graph.connections.values():
if connection.target.block == component.id and connection.target.port not in input_ids:
raise ValueError(
f"Input {connection.target.port!r} is still connected in the containing graph"
)
if connection.source.block == component.id and connection.source.port not in output_ids:
raise ValueError(
f"Output {connection.source.port!r} is still connected in the containing graph"
)
old = {
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"source": deepcopy(component.source),
}
new = {
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"source": deepcopy(source),
}
if old != new:
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
def edit_component_appearance(
self,
component_id: str,
name: str,
shape: str,
fill: str,
border: str,
text: str,
show_subtree: bool,
) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
old = {
"name": component.name,
**component.icon.to_dict(),
"show_subtree": component.show_subtree_in_library,
}
new = {
"name": name,
"shape": shape,
"fill": fill,
"border": border,
"text": text,
"show_subtree": show_subtree,
}
if old != new:
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
def delete_selection(
self,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
return
graph = component.graph
all_connection_ids = set(connection_ids)
for connection in graph.connections.values():
if (
connection.source.block in block_ids
or connection.target.block in block_ids
or connection.source.interface in input_ids
or connection.target.interface in output_ids
):
all_connection_ids.add(connection.id)
blocks = {block_id: graph.blocks[block_id] for block_id in block_ids if block_id in graph.blocks}
connections = {
connection_id: graph.connections[connection_id]
for connection_id in all_connection_ids
if connection_id in graph.connections
}
inputs = [port for port in component.inputs if port.id in input_ids]
outputs = [port for port in component.outputs if port.id in output_ids]
if not (blocks or connections or inputs or outputs):
return
self.undo_stack.push(
DeleteSelectionCommand(
self,
component.id,
blocks,
connections,
inputs,
outputs,
)
)
def paste_selection(
self,
source_components: list[Component],
source_connections: list[Connection],
offset: QPointF,
) -> list[str]:
owner = self.active_component
if owner is None or owner.implementation_kind != "graph":
return []
pairs = [(source, clone_component(source)) for source in source_components]
id_map = {source.id: clone.id for source, clone in pairs}
blocks = {}
for _source, clone in pairs:
clone.x += offset.x()
clone.y += offset.y()
blocks[clone.id] = clone
connections = {}
for source in source_connections:
if source.source.block not in id_map or source.target.block not in id_map:
continue
connection = Connection(
id=str(uuid4()),
source=Endpoint(block=id_map[source.source.block], port=source.source.port),
target=Endpoint(block=id_map[source.target.block], port=source.target.port),
)
connections[connection.id] = connection
if blocks:
self.undo_stack.push(
PasteSelectionCommand(self, owner.id, blocks, connections)
)
return list(blocks)
def _graph_for(self, owner_id: str):
if self.document is None:
raise ValueError("There is no open document")
owner = self.document.find_component(owner_id)
if owner is None:
raise ValueError("The containing component is no longer in the document")
return owner.graph
def _insert_component(self, owner_id: str | None, component: Component) -> None:
if self.document is None:
raise ValueError("There is no open document")
if owner_id is None:
self.document.roots[component.id] = component
else:
self._graph_for(owner_id).blocks[component.id] = component
if owner_id == self.active_component_id:
self.componentAdded.emit(component.id)
self.documentReset.emit()
def _remove_component(self, owner_id: str | None, component_id: str) -> None:
if self.document is None:
return
if owner_id is None:
self.document.roots.pop(component_id, None)
if self.active_component_id == component_id:
self.active_component_id = next(iter(self.document.roots), None)
self.activeGraphChanged.emit()
else:
self._graph_for(owner_id).blocks.pop(component_id, None)
if owner_id == self.active_component_id:
self.componentRemoved.emit(component_id)
self.documentReset.emit()
def _move_component(self, owner_id: str, component_id: str, position: QPointF) -> None:
component = self._graph_for(owner_id).blocks[component_id]
component.x, component.y = position.x(), position.y()
if owner_id == self.active_component_id:
self.componentMoved.emit(component_id, position)
self.documentReset.emit()
def _rotate_component(
self, owner_id: str, component_id: str, rotation: float
) -> None:
component = self._graph_for(owner_id).blocks.get(component_id)
if component is None:
return
component.rotation = rotation
if owner_id == self.active_component_id:
self.componentRotated.emit(component_id, rotation)
def _insert_connection(self, owner_id: str, connection: Connection) -> None:
self._graph_for(owner_id).connections[connection.id] = connection
if owner_id == self.active_component_id:
self.connectionAdded.emit(connection.id)
self.documentReset.emit()
def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, None)
if owner_id == self.active_component_id:
self.connectionRemoved.emit(connection_id)
self.documentReset.emit()
def _replace_component(self, old_id: str, replacement: Component) -> None:
if self.document is None:
return
was_active = self.active_component_id == old_id
if old_id in self.document.roots:
self.document.roots.pop(old_id)
self.document.roots[replacement.id] = replacement
else:
parent = self.document.find_parent(old_id)
if parent is None:
raise ValueError("The component is no longer in this document")
parent.graph.blocks.pop(old_id)
parent.graph.blocks[replacement.id] = replacement
if was_active:
self.active_component_id = replacement.id
self.document.validate()
self.documentReset.emit()
if was_active:
self.activeGraphChanged.emit()
def _insert_interface_port(self, owner_id: str, direction: str, port: Port) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
if all(existing.id != port.id for existing in ports):
ports.append(port)
self.interfaceChanged.emit()
self.documentReset.emit()
def _remove_interface_port(self, owner_id: str, direction: str, port_id: str) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
ports[:] = [port for port in ports if port.id != port_id]
self.interfaceChanged.emit()
self.documentReset.emit()
def _move_interface_port(self, owner_id: str, port_id: str, position: QPointF) -> None:
if self.document is None:
return
owner = self.document.find_component(owner_id)
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None:
port.x, port.y = position.x(), position.y()
self.interfaceChanged.emit()
self.documentReset.emit()
def _rename_interface_port(self, owner_id: str, port_id: str, name: str) -> None:
owner = self.document.find_component(owner_id) if self.document else None
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
if port is not None:
port.name = name
self.interfaceChanged.emit()
self.documentReset.emit()
def _rename_connection(self, owner_id: str, connection_id: str, name: str) -> None:
connection = self._graph_for(owner_id).connections.get(connection_id)
if connection is not None:
connection.name = name
self.documentReset.emit()
def _replace_source(self, component_id: str, source: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.source = deepcopy(source)
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
def _set_component_appearance(self, component_id: str, values: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.name = values["name"]
component.icon = Icon(
shape=values["shape"],
fill=values["fill"],
border=values["border"],
text=values["text"],
)
component.show_subtree_in_library = values["show_subtree"]
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()
def _delete_items(
self,
owner_id: str | None,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
) -> None:
if self.document is None:
return
deleted_component_ids: set[str] = set()
for block_id in block_ids:
component = self.document.find_component(block_id)
if component is not None:
deleted_component_ids.update(
child.id for child in self._component_subtree(component)
)
active_was_deleted = self.active_component_id in deleted_component_ids
if owner_id is None:
for block_id in block_ids:
self.document.roots.pop(block_id, None)
else:
owner = self.document.find_component(owner_id)
if owner is None:
return
for block_id in block_ids:
owner.graph.blocks.pop(block_id, None)
for connection_id in connection_ids:
owner.graph.connections.pop(connection_id, None)
owner.inputs[:] = [port for port in owner.inputs if port.id not in input_ids]
owner.outputs[:] = [port for port in owner.outputs if port.id not in output_ids]
if active_was_deleted:
self.active_component_id = owner_id or next(iter(self.document.roots), None)
self.activeGraphChanged.emit()
self.documentReset.emit()
def _restore_items(
self,
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
) -> None:
if self.document is None:
return
if owner_id is None:
self.document.roots.update(blocks)
else:
owner = self.document.find_component(owner_id)
if owner is None:
return
owner.graph.blocks.update(blocks)
owner.graph.connections.update(connections)
existing_inputs = {port.id for port in owner.inputs}
existing_outputs = {port.id for port in owner.outputs}
owner.inputs.extend(port for port in inputs if port.id not in existing_inputs)
owner.outputs.extend(port for port in outputs if port.id not in existing_outputs)
self.documentReset.emit()
@staticmethod
def _component_subtree(component: Component):
yield component
for child in component.graph.blocks.values():
yield from DocumentController._component_subtree(child)
def _set_text_definition(self, component_id: str, values: dict) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
component.inputs = [Port.from_dict(item) for item in values["inputs"]]
component.outputs = [Port.from_dict(item) for item in values["outputs"]]
component.source = deepcopy(values["source"])
self.interfaceChanged.emit()
self.documentReset.emit()
if component_id == self.active_component_id:
self.activeGraphChanged.emit()

View File

@@ -0,0 +1,329 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
@dataclass
class Port:
id: str
name: str
x: float = 0.0
y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Port":
position = data.get("position", {})
return cls(
id=str(data["id"]),
name=str(data.get("name", data["id"])),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
properties=dict(data.get("properties", {})),
)
@dataclass
class Icon:
shape: str = "rectangle"
fill: str = "#f4f4f4"
border: str = "#303030"
text: str = ""
def to_dict(self) -> dict[str, str]:
return {
"shape": self.shape,
"fill": self.fill,
"border": self.border,
"text": self.text,
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {}
return cls(
shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")),
text=str(data.get("text", "")),
)
@dataclass(frozen=True)
class Endpoint:
block: str | None = None
port: str | None = None
interface: str | None = None
def to_dict(self) -> dict[str, str]:
if self.interface is not None:
return {"interface": self.interface}
if self.block is None or self.port is None:
raise ValueError("A block endpoint requires both block and port")
return {"block": self.block, "port": self.port}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Endpoint":
if "interface" in data:
return cls(interface=str(data["interface"]))
return cls(block=str(data["block"]), port=str(data["port"]))
@dataclass
class Connection:
id: str
source: Endpoint
target: Endpoint
name: str = ""
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"source": self.source.to_dict(),
"target": self.target.to_dict(),
"name": self.name,
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Connection":
return cls(
id=str(data["id"]),
source=Endpoint.from_dict(data["source"]),
target=Endpoint.from_dict(data["target"]),
name=str(data.get("name", "")),
properties=dict(data.get("properties", {})),
)
@dataclass
class Graph:
blocks: dict[str, Component] = field(default_factory=dict)
connections: dict[str, Connection] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"blocks": [block.to_dict() for block in self.blocks.values()],
"connections": [connection.to_dict() for connection in self.connections.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Graph":
data = data or {}
blocks = [Component.from_dict(item) for item in data.get("blocks", [])]
connections = [Connection.from_dict(item) for item in data.get("connections", [])]
if len({block.id for block in blocks}) != len(blocks):
raise ValueError("A graph contains duplicate component IDs")
if len({connection.id for connection in connections}) != len(connections):
raise ValueError("A graph contains duplicate connection IDs")
return cls(
blocks={block.id: block for block in blocks},
connections={connection.id: connection for connection in connections},
)
@dataclass
class Component:
id: str
name: str
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
inputs: list[Port] = field(default_factory=list)
outputs: list[Port] = field(default_factory=list)
icon: Icon = field(default_factory=Icon)
properties: dict[str, Any] = field(default_factory=dict)
implementation_kind: str = "graph"
graph: Graph = field(default_factory=Graph)
source: dict[str, Any] = field(default_factory=dict)
show_subtree_in_library: bool = True
def to_dict(self) -> dict[str, Any]:
implementation = {"kind": self.implementation_kind}
if self.implementation_kind == "text":
implementation["source"] = self.source
else:
implementation["graph"] = self.graph.to_dict()
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"rotation": self.rotation,
"interface": {
"inputs": [port.to_dict() for port in self.inputs],
"outputs": [port.to_dict() for port in self.outputs],
},
"icon": self.icon.to_dict(),
"properties": self.properties,
"library": {"showSubtree": self.show_subtree_in_library},
"implementation": implementation,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Component":
interface = data.get("interface", {})
position = data.get("position", {})
implementation = data["implementation"]
kind = str(implementation.get("kind", "graph"))
if kind not in {"graph", "text"}:
raise ValueError(f"Unknown component implementation kind: {kind}")
return cls(
id=str(data["id"]),
name=str(data.get("name", "Unnamed")),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
rotation=float(data.get("rotation", 0.0)),
inputs=[Port.from_dict(item) for item in interface.get("inputs", [])],
outputs=[Port.from_dict(item) for item in interface.get("outputs", [])],
icon=Icon.from_dict(data.get("icon")),
properties=dict(data.get("properties", {})),
show_subtree_in_library=bool(data.get("library", {}).get("showSubtree", True)),
implementation_kind=kind,
graph=Graph.from_dict(implementation.get("graph")) if kind == "graph" else Graph(),
source=dict(implementation.get("source", {})) if kind == "text" else {},
)
@dataclass
class GraphDocument:
roots: dict[str, Component] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def empty(cls) -> "GraphDocument":
return cls(metadata={"name": "Untitled"})
def to_dict(self) -> dict[str, Any]:
return {
"format": "bedit-document",
"version": 1,
"metadata": self.metadata,
"roots": [root.to_dict() for root in self.roots.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "GraphDocument":
if data.get("format") != "bedit-document":
raise ValueError("This is not a BEdit document")
if data.get("version") != 1:
raise ValueError(f"Unsupported BEdit document version: {data.get('version')}")
roots = [Component.from_dict(item) for item in data["roots"]]
if len({root.id for root in roots}) != len(roots):
raise ValueError("The document contains duplicate root IDs")
document = cls(
roots={root.id: root for root in roots},
metadata=dict(data.get("metadata", {})),
)
document.validate()
return document
def all_components(self):
def walk(component: Component):
yield component
if component.implementation_kind == "graph":
for child in component.graph.blocks.values():
yield from walk(child)
def all_roots():
for root in self.roots.values():
yield from walk(root)
return all_roots()
def find_component(self, component_id: str) -> Component | None:
return next(
(component for component in self.all_components() if component.id == component_id),
None,
)
def find_parent(self, component_id: str) -> Component | None:
for component in self.all_components():
if component_id in component.graph.blocks:
return component
return None
def validate(self) -> None:
seen: set[str] = set()
for component in self.all_components():
if component.id in seen:
raise ValueError(f"Duplicate component ID: {component.id}")
seen.add(component.id)
if component.implementation_kind == "text" and component.graph.blocks:
raise ValueError(f"Text component {component.name} cannot contain a graph")
self._validate_graph(component)
@staticmethod
def _validate_graph(owner: Component) -> None:
input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs}
for connection in owner.graph.connections.values():
if connection.source.interface is not None:
if connection.source.interface not in input_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface input")
else:
source = owner.graph.blocks.get(connection.source.block or "")
if source is None or connection.source.port not in {p.id for p in source.outputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block output")
if connection.target.interface is not None:
if connection.target.interface not in output_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface output")
else:
target = owner.graph.blocks.get(connection.target.block or "")
if target is None or connection.target.port not in {p.id for p in target.inputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block input")
def clone_component(source: Component) -> Component:
"""Deep-copy a component tree and remap every owned object ID."""
def clone(current: Component) -> Component:
child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()]
child_ids = {old.id: new.id for old, new in child_pairs}
def remap(endpoint: Endpoint) -> Endpoint:
if endpoint.interface is not None:
return endpoint
return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port)
graph = Graph(
blocks={new.id: new for _old, new in child_pairs},
connections={
new_id: Connection(
new_id,
remap(connection.source),
remap(connection.target),
connection.name,
deepcopy(connection.properties),
)
for connection in current.graph.connections.values()
for new_id in [str(uuid4())]
},
)
return Component(
id=str(uuid4()),
name=current.name,
x=current.x,
y=current.y,
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs],
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs],
icon=Icon(**current.icon.to_dict()),
properties=deepcopy(current.properties),
implementation_kind=current.implementation_kind,
graph=graph if current.implementation_kind == "graph" else Graph(),
source=deepcopy(current.source),
show_subtree_in_library=current.show_subtree_in_library,
)
return clone(source)

View File

@@ -0,0 +1,23 @@
import json
from pathlib import Path
from bedit.document.model import GraphDocument
class JsonDocumentSerializer:
@staticmethod
def load(path: Path) -> GraphDocument:
with path.open(encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, dict):
raise ValueError("The graph file must contain a JSON object")
return GraphDocument.from_dict(data)
@staticmethod
def save(document: GraphDocument, path: Path) -> None:
temporary_path = path.with_suffix(path.suffix + ".tmp")
with temporary_path.open("w", encoding="utf-8") as file:
json.dump(document.to_dict(), file, indent=2)
file.write("\n")
temporary_path.replace(path)

View File

@@ -0,0 +1,42 @@
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLineEdit,
QMessageBox,
QVBoxLayout,
)
class ItemOptionsDialog(QDialog):
"""Small, extensible options dialog shared by ports and connections."""
def __init__(self, title: str, name: str, parent=None, *, name_required: bool = True) -> None:
super().__init__(parent)
self.name_required = name_required
self.setWindowTitle(title)
self.resize(380, 120)
layout = QVBoxLayout(self)
self.form = QFormLayout()
self.name_edit = QLineEdit(name, self)
self.form.addRow("Name:", self.name_edit)
layout.addLayout(self.form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
@property
def name(self) -> str:
return self.name_edit.text().strip()
def accept(self) -> None:
if self.name_required and not self.name:
QMessageBox.warning(self, "Invalid name", "The name cannot be empty.")
return
super().accept()

View File

@@ -0,0 +1,3 @@
from bedit.library.repository import LibraryRepository, default_library_paths
__all__ = ["LibraryRepository", "default_library_paths"]

View File

@@ -0,0 +1,56 @@
import json
from dataclasses import dataclass
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from bedit.document.model import GraphDocument
@dataclass(frozen=True)
class LibraryDocument:
name: str
document: GraphDocument
source_path: str
def bundled_library_path() -> Path:
return Path(__file__).resolve().parents[1] / "data" / "libraries" / "example.json"
def default_library_paths() -> list[str]:
return [str(bundled_library_path())]
class LibraryRepository(QObject):
librariesChanged = Signal()
loadWarningsChanged = Signal(list)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.libraries: list[LibraryDocument] = []
self.load_warnings: list[str] = []
def load_paths(self, paths: list[str]) -> None:
libraries: list[LibraryDocument] = []
warnings: list[str] = []
for raw_path in paths:
path = Path(raw_path).expanduser()
candidates = sorted(path.glob("*.json")) if path.is_dir() else [path]
for candidate in candidates:
try:
libraries.append(self._load_file(candidate))
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error:
warnings.append(f"{candidate}: {error}")
self.libraries = libraries
self.load_warnings = warnings
self.librariesChanged.emit()
self.loadWarningsChanged.emit(warnings)
@staticmethod
def _load_file(path: Path) -> LibraryDocument:
with path.open(encoding="utf-8") as file:
data = json.load(file)
document = GraphDocument.from_dict(data)
name = str(document.metadata.get("name") or path.stem)
return LibraryDocument(name, document, str(path))

View File

@@ -0,0 +1,91 @@
import json
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from bedit.document.controller import DocumentController
from bedit.document.model import Component
from bedit.library.repository import LibraryRepository
COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1
COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2
ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3
COMPONENT_MIME_TYPE = "application/x-bedit-component"
class LibraryTreeModel(QStandardItemModel):
rebuilt = Signal()
def __init__(
self,
repository: LibraryRepository,
controller: DocumentController,
parent=None,
) -> None:
super().__init__(parent)
self.repository = repository
self.controller = controller
repository.librariesChanged.connect(self.rebuild)
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Libraries"])
for library in self.repository.libraries:
root = QStandardItem(library.name)
root.setDragEnabled(False)
root.setToolTip(library.source_path)
for component in library.document.roots.values():
root.appendRow(self._component_item(component))
self.appendRow(root)
self.rebuilt.emit()
def _component_item(self, component: Component, current: bool = False) -> QStandardItem:
item = QStandardItem(component.name)
item.setEditable(False)
item.setData(component.to_dict(), COMPONENT_ROLE)
item.setData(component.id, COMPONENT_ID_ROLE)
item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE)
if component.show_subtree_in_library:
for child in component.graph.blocks.values():
item.appendRow(self._component_item(child, current=current))
return item
def mimeTypes(self) -> list[str]: # noqa: N802
return [COMPONENT_MIME_TYPE]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
mime_data = QMimeData()
for index in indexes:
component = index.data(COMPONENT_ROLE)
if component:
encoded = json.dumps(component).encode("utf-8")
mime_data.setData(COMPONENT_MIME_TYPE, QByteArray(encoded))
break
return mime_data
def supportedDragActions(self): # noqa: N802
return Qt.DropAction.CopyAction
class DocumentTreeModel(LibraryTreeModel):
def __init__(self, controller: DocumentController, parent=None) -> None:
QStandardItemModel.__init__(self, parent)
self.controller = controller
controller.documentReset.connect(self.rebuild)
controller.componentMoved.connect(lambda _component_id, _position: self.rebuild())
controller.connectionAdded.connect(lambda _connection_id: self.rebuild())
controller.connectionRemoved.connect(lambda _connection_id: self.rebuild())
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Document"])
if self.controller.document is not None:
current_root = QStandardItem("Current Document")
current_root.setDragEnabled(False)
current_root.setData("current-document", ITEM_KIND_ROLE)
for component in self.controller.document.roots.values():
current_root.appendRow(self._component_item(component, current=True))
self.appendRow(current_root)
self.rebuilt.emit()

View File

@@ -0,0 +1,475 @@
import json
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox
from bedit.component_options_dialog import ComponentOptionsDialog
from bedit.document.controller import DocumentController
from bedit.document.model import Port
from bedit.item_options_dialog import ItemOptionsDialog
from bedit.library.repository import LibraryRepository
from bedit.library.tree_model import (
COMPONENT_ID_ROLE,
ITEM_KIND_ROLE,
DocumentTreeModel,
LibraryTreeModel,
)
from bedit.settings_dialog import SettingsDialog
from bedit.ui_main_window import Ui_MainWindow
class MainWindow(QMainWindow):
"""Application shell and owner of the single active document."""
def __init__(self) -> None:
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.settings = QSettings()
self.libraries = LibraryRepository(self)
self.document_controller = DocumentController(self)
self.library_tree_model = LibraryTreeModel(
self.libraries,
self.document_controller,
self,
)
self.document_tree_model = DocumentTreeModel(self.document_controller, self)
self._configure_models()
self._connect_actions()
self._populate_view_menu()
self._restore_window_geometry()
self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget)
self.ui.leftDockHost.show()
self.ui.panel_libraries.show()
self.ui.panel_document.show()
self.ui.leftDockHost.splitDockWidget(
self.ui.panel_document,
self.ui.panel_libraries,
Qt.Orientation.Vertical,
)
self.ui.workspaceSplitter.setSizes([280, 720])
self.reload_libraries()
self._active_graph_changed()
self._update_title()
def _configure_models(self) -> None:
self.ui.treeView.setModel(self.library_tree_model)
self.ui.treeView.setHeaderHidden(True)
self.ui.treeView.setDragEnabled(True)
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
self.ui.documentTreeView.setModel(self.document_tree_model)
self.ui.documentTreeView.setHeaderHidden(True)
self.ui.documentTreeView.setDragEnabled(True)
self.ui.documentTreeView.setDragDropMode(
self.ui.documentTreeView.DragDropMode.DragOnly
)
self.ui.documentTreeView.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.ui.documentTreeView.customContextMenuRequested.connect(
self.show_library_context_menu
)
self.ui.documentTreeView.doubleClicked.connect(self.activate_tree_component)
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
self.ui.graphView.set_model(self.document_controller)
self.ui.graphView.componentOptionsRequested.connect(self.show_component_options)
self.ui.graphView.portOptionsRequested.connect(self.show_port_options)
self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
self.ui.graphView.selectionAvailabilityChanged.connect(
lambda _available: self._update_edit_actions()
)
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input"))
self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output"))
self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer"))
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
def _connect_actions(self) -> None:
self.ui.actionNew.triggered.connect(self.new_document)
self.ui.actionOpen.triggered.connect(self.open_document)
self.ui.actionSave.triggered.connect(self.save_document)
self.ui.actionSaveAs.triggered.connect(self.save_document_as)
self.ui.actionClose.triggered.connect(self.close_document)
self.ui.actionExit.triggered.connect(self.close)
self.ui.actionSettings.triggered.connect(self.show_settings)
self.ui.actionAbout.triggered.connect(self.show_about)
self.ui.actionAboutQt.triggered.connect(
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
)
self.ui.actionUndo.triggered.connect(self.document_controller.undo_stack.undo)
self.ui.actionRedo.triggered.connect(self.document_controller.undo_stack.redo)
self.ui.actionDelete.triggered.connect(self.ui.graphView.delete_selected)
self.ui.actionCopy.triggered.connect(self.ui.graphView.copy_selection)
self.ui.actionCut.triggered.connect(self.ui.graphView.cut_selection)
self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection)
self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected)
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title())
self.document_controller.filePathChanged.connect(lambda _path: self._update_title())
self.document_controller.documentOpenedChanged.connect(self._document_opened_changed)
self.ui.actionUndo.setEnabled(False)
self.ui.actionRedo.setEnabled(False)
self._update_edit_actions()
self._document_opened_changed(False)
def _populate_view_menu(self) -> None:
for panel in (self.ui.panel_document, self.ui.panel_libraries):
self.ui.menuPanels.addAction(panel.toggleViewAction())
for toolbar in (
self.ui.fileToolbar,
self.ui.editToolbar,
self.ui.transformToolbar,
):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
def reload_libraries(self) -> None:
self.libraries.load_paths(SettingsDialog.library_paths(self.settings))
self.ui.treeView.expandAll()
if self.libraries.load_warnings:
QMessageBox.warning(
self,
"Some libraries could not be loaded",
"\n".join(self.libraries.load_warnings),
)
def _restore_window_geometry(self) -> None:
geometry = self.settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
def _update_title(self) -> None:
if self.document_controller.document is None:
self.setWindowTitle("BEdit")
return
name = self.document_controller.file_path.name if self.document_controller.file_path else "Untitled"
modified = "*" if not self.document_controller.undo_stack.isClean() else ""
self.setWindowTitle(f"{modified}{name} — BEdit")
def _active_graph_changed(self) -> None:
component = self.document_controller.active_component
if component is None:
self.ui.graphBreadcrumbLabel.setText("No component selected")
self.ui.navigateUpButton.setEnabled(False)
self.ui.workspaceModeLabel.setText("")
self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
self.ui.applyJsonButton.setVisible(False)
for button in (
self.ui.pointerToolButton,
self.ui.inputToolButton,
self.ui.outputToolButton,
):
button.setVisible(False)
self._update_edit_actions()
return
self.ui.graphBreadcrumbLabel.setText(" ".join(self.document_controller.breadcrumb()))
self.ui.navigateUpButton.setEnabled(
self.document_controller.document.find_parent(component.id) is not None
)
is_graph = component.implementation_kind == "graph"
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
self.ui.applyJsonButton.setVisible(not is_graph)
for button in (
self.ui.pointerToolButton,
self.ui.inputToolButton,
self.ui.outputToolButton,
):
button.setVisible(is_graph)
if is_graph:
self.set_graph_tool("pointer")
else:
self._load_source_json()
self._update_edit_actions()
def _update_edit_actions(self) -> None:
component = self.document_controller.active_component
is_graph = component is not None and component.implementation_kind == "graph"
has_selection = bool(self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems())
for action in (self.ui.actionCut, self.ui.actionCopy, self.ui.actionDelete):
action.setEnabled(is_graph and has_selection)
self.ui.actionRotateClockwise.setEnabled(
is_graph and self.ui.graphView.has_selected_components()
)
self.ui.actionSelectAll.setEnabled(is_graph)
self.ui.actionPaste.setEnabled(is_graph)
@Slot()
def navigate_up(self) -> None:
if self._resolve_source_edits():
self.document_controller.navigate_up()
def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode(mode)
buttons = {
"pointer": self.ui.pointerToolButton,
"input": self.ui.inputToolButton,
"output": self.ui.outputToolButton,
}
buttons[mode].setChecked(True)
def _load_source_json(self) -> None:
component = self.document_controller.active_component
if component is None:
return
text = json.dumps(
{
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"source": component.source,
},
indent=2,
)
self.ui.jsonEditor.setPlainText(text)
self.ui.jsonEditor.document().setModified(False)
def _resolve_source_edits(self) -> bool:
component = self.document_controller.active_component
if (
component is None
or component.implementation_kind != "text"
or not self.ui.jsonEditor.document().isModified()
):
return True
answer = QMessageBox.question(
self,
"Apply text component changes?",
"The text component has unapplied input, output, or source changes.",
QMessageBox.StandardButton.Apply
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Apply:
return self.apply_json()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def apply_json(self) -> bool:
try:
data = json.loads(self.ui.jsonEditor.toPlainText())
if not isinstance(data, dict):
raise ValueError("The text component JSON must be an object")
if not isinstance(data.get("inputs"), list):
raise ValueError("'inputs' must be a list")
if not isinstance(data.get("outputs"), list):
raise ValueError("'outputs' must be a list")
if not isinstance(data.get("source"), dict):
raise ValueError("'source' must be an object")
inputs = [Port.from_dict(item) for item in data["inputs"]]
outputs = [Port.from_dict(item) for item in data["outputs"]]
self.document_controller.replace_active_text_definition(
inputs, outputs, data["source"]
)
except (TypeError, ValueError, json.JSONDecodeError) as error:
QMessageBox.critical(self, "Invalid text component JSON", str(error))
return False
self._load_source_json()
return True
def _maybe_save(self) -> bool:
if self.document_controller.document is None:
return True
if self.document_controller.undo_stack.isClean():
return True
answer = QMessageBox.warning(
self,
"Unsaved changes",
"The current graph contains unsaved changes.",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Save:
return self.save_document()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def new_document(self) -> None:
if self._resolve_source_edits() and self._maybe_save():
self.document_controller.new_document()
@Slot()
def close_document(self) -> None:
if self._resolve_source_edits() and self._maybe_save():
self.document_controller.close_document()
@Slot()
def open_document(self) -> None:
if not self._resolve_source_edits() or not self._maybe_save():
return
filename, _ = QFileDialog.getOpenFileName(
self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)"
)
if not filename:
return
try:
self.document_controller.load(Path(filename))
except (OSError, ValueError) as error:
QMessageBox.critical(self, "Could not open graph", str(error))
@Slot()
def save_document(self) -> bool:
if self.document_controller.document is None:
return False
if self.document_controller.file_path is None:
return self.save_document_as()
try:
self.document_controller.save()
except OSError as error:
QMessageBox.critical(self, "Could not save graph", str(error))
return False
return True
@Slot()
def save_document_as(self) -> bool:
if self.document_controller.document is None:
return False
filename, _ = QFileDialog.getSaveFileName(
self,
"Save graph",
"untitled.bedit.json",
"BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)",
)
if not filename:
return False
try:
self.document_controller.save(Path(filename))
except OSError as error:
QMessageBox.critical(self, "Could not save graph", str(error))
return False
return True
@Slot()
def show_settings(self) -> None:
dialog = SettingsDialog(self)
dialog.settingsChanged.connect(self.reload_libraries)
dialog.exec()
def _document_opened_changed(self, opened: bool) -> None:
for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
action.setEnabled(opened)
self._active_graph_changed()
@Slot(object)
def activate_tree_component(self, index) -> None:
if index.data(ITEM_KIND_ROLE) != "current-component":
return
component_id = index.data(COMPONENT_ID_ROLE)
if component_id:
self.document_controller.activate_component(component_id)
@Slot(object)
def show_library_context_menu(self, position) -> None:
tree_view = self.ui.documentTreeView
index = tree_view.indexAt(position)
kind = index.data(ITEM_KIND_ROLE)
if kind == "current-component":
component_id = index.data(COMPONENT_ID_ROLE)
document = self.document_controller.document
component = document.find_component(component_id) if document else None
if component is None:
return
menu = QMenu(self)
graph_action = None
text_action = None
if component.implementation_kind == "graph":
graph_action = menu.addAction("New Graph Block")
text_action = menu.addAction("New Text Block")
menu.addSeparator()
options_action = menu.addAction("Component Options…")
delete_action = menu.addAction("Delete")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action:
self.document_controller.add_child(component_id, "graph")
elif selected is text_action:
self.document_controller.add_child(component_id, "text")
elif selected is options_action:
self.show_component_options(component_id)
elif selected is delete_action:
answer = QMessageBox.question(
self,
"Delete component?",
f"Delete {component.name!r} and all of its contents?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
self.document_controller.delete_component(component_id)
return
if kind != "current-document":
return
menu = QMenu(self)
graph_action = menu.addAction("New Graph Block")
text_action = menu.addAction("New Text Block")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action:
self.document_controller.add_root("graph")
elif selected is text_action:
self.document_controller.add_root("text")
@Slot(str)
def show_component_options(self, component_id: str) -> None:
document = self.document_controller.document
component = document.find_component(component_id) if document else None
if component is None:
return
dialog = ComponentOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.edit_component_appearance(
component_id,
dialog.ui.nameEdit.text().strip(),
dialog.ui.shapeCombo.currentText(),
dialog.ui.fillEdit.text(),
dialog.ui.borderEdit.text(),
dialog.ui.iconTextEdit.text(),
dialog.ui.showSubtreeCheckBox.isChecked(),
)
@Slot(str, str)
def show_port_options(self, port_id: str, direction: str) -> None:
owner = self.document_controller.active_component
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
port = next((item for item in ports if item.id == port_id), None)
if port is None:
return
dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.rename_interface_port(port_id, dialog.name)
@Slot(str)
def show_connection_options(self, connection_id: str) -> None:
owner = self.document_controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
connection = owner.graph.connections.get(connection_id)
if connection is None:
return
dialog = ItemOptionsDialog(
"Connection Options", connection.name, self, name_required=False
)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.rename_connection(connection_id, dialog.name)
@Slot()
def show_about(self) -> None:
QMessageBox.about(
self,
"About BEdit",
"<h3>BEdit</h3><p>A graphical editor built with Python and Qt.</p>",
)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
if not self._resolve_source_edits() or not self._maybe_save():
event.ignore()
return
self.settings.setValue("window/geometry", self.saveGeometry())
event.accept()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
from pathlib import Path
from PySide6.QtCore import QSettings, Signal
from PySide6.QtWidgets import QDialog, QFileDialog
from bedit.library.repository import default_library_paths
from bedit.ui_settings_dialog import Ui_SettingsDialog
class SettingsDialog(QDialog):
"""Edit application preferences defined in the Designer form."""
settingsChanged = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_SettingsDialog()
self.ui.setupUi(self)
self.settings = QSettings()
self.ui.addLibraryFileButton.clicked.connect(self._add_library_file)
self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder)
self.ui.removeLibraryPathButton.clicked.connect(self._remove_library_path)
self.ui.libraryPathsList.itemSelectionChanged.connect(self._update_remove_button)
self._load_settings()
def _load_settings(self) -> None:
self.ui.autosaveGroupBox.setChecked(
self.settings.value("general/autosaveEnabled", False, type=bool)
)
self.ui.autosaveIntervalSpinBox.setValue(
self.settings.value("general/autosaveInterval", 5, type=int)
)
self.ui.libraryPathsList.clear()
self.ui.libraryPathsList.addItems(self.library_paths(self.settings))
self._update_remove_button()
@staticmethod
def library_paths(settings: QSettings | None = None) -> list[str]:
settings = settings or QSettings()
value = settings.value("libraries/paths", default_library_paths())
if isinstance(value, str):
return [value]
return [str(path) for path in value]
def _add_library_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Add library",
"",
"BEdit libraries (*.json);;All files (*)",
)
if path:
self._append_unique_path(path)
def _add_library_folder(self) -> None:
path = QFileDialog.getExistingDirectory(self, "Add library folder")
if path:
self._append_unique_path(path)
def _append_unique_path(self, path: str) -> None:
normalized = str(Path(path).expanduser().resolve())
existing = {
self.ui.libraryPathsList.item(row).text()
for row in range(self.ui.libraryPathsList.count())
}
if normalized not in existing:
self.ui.libraryPathsList.addItem(normalized)
def _remove_library_path(self) -> None:
for item in self.ui.libraryPathsList.selectedItems():
self.ui.libraryPathsList.takeItem(self.ui.libraryPathsList.row(item))
def _update_remove_button(self) -> None:
self.ui.removeLibraryPathButton.setEnabled(bool(self.ui.libraryPathsList.selectedItems()))
def accept(self) -> None:
self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked())
self.settings.setValue(
"general/autosaveInterval", self.ui.autosaveIntervalSpinBox.value()
)
paths = [
self.ui.libraryPathsList.item(row).text()
for row in range(self.ui.libraryPathsList.count())
]
self.settings.setValue("libraries/paths", paths)
self.settings.sync()
self.settingsChanged.emit()
super().accept()

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'component_options_dialog.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox,
QDialog, QDialogButtonBox, QFormLayout, QLabel,
QLineEdit, QSizePolicy, QSpacerItem, QVBoxLayout,
QWidget)
class Ui_ComponentOptionsDialog(object):
def setupUi(self, ComponentOptionsDialog):
if not ComponentOptionsDialog.objectName():
ComponentOptionsDialog.setObjectName(u"ComponentOptionsDialog")
ComponentOptionsDialog.resize(420, 260)
self.dialogLayout = QVBoxLayout(ComponentOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.optionsForm = QFormLayout()
self.optionsForm.setObjectName(u"optionsForm")
self.nameLabel = QLabel(ComponentOptionsDialog)
self.nameLabel.setObjectName(u"nameLabel")
self.optionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
self.nameEdit = QLineEdit(ComponentOptionsDialog)
self.nameEdit.setObjectName(u"nameEdit")
self.optionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.shapeLabel = QLabel(ComponentOptionsDialog)
self.shapeLabel.setObjectName(u"shapeLabel")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.shapeLabel)
self.shapeCombo = QComboBox(ComponentOptionsDialog)
self.shapeCombo.addItem("")
self.shapeCombo.addItem("")
self.shapeCombo.setObjectName(u"shapeCombo")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.shapeCombo)
self.iconTextLabel = QLabel(ComponentOptionsDialog)
self.iconTextLabel.setObjectName(u"iconTextLabel")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.iconTextLabel)
self.iconTextEdit = QLineEdit(ComponentOptionsDialog)
self.iconTextEdit.setObjectName(u"iconTextEdit")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.iconTextEdit)
self.fillLabel = QLabel(ComponentOptionsDialog)
self.fillLabel.setObjectName(u"fillLabel")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.fillLabel)
self.fillEdit = QLineEdit(ComponentOptionsDialog)
self.fillEdit.setObjectName(u"fillEdit")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.fillEdit)
self.borderLabel = QLabel(ComponentOptionsDialog)
self.borderLabel.setObjectName(u"borderLabel")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.borderLabel)
self.borderEdit = QLineEdit(ComponentOptionsDialog)
self.borderEdit.setObjectName(u"borderEdit")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.borderEdit)
self.showSubtreeCheckBox = QCheckBox(ComponentOptionsDialog)
self.showSubtreeCheckBox.setObjectName(u"showSubtreeCheckBox")
self.showSubtreeCheckBox.setChecked(True)
self.optionsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox)
self.dialogLayout.addLayout(self.optionsForm)
self.optionsSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.dialogLayout.addItem(self.optionsSpacer)
self.buttonBox = QDialogButtonBox(ComponentOptionsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(ComponentOptionsDialog)
self.buttonBox.accepted.connect(ComponentOptionsDialog.accept)
self.buttonBox.rejected.connect(ComponentOptionsDialog.reject)
QMetaObject.connectSlotsByName(ComponentOptionsDialog)
# setupUi
def retranslateUi(self, ComponentOptionsDialog):
ComponentOptionsDialog.setWindowTitle(QCoreApplication.translate("ComponentOptionsDialog", u"Component Options", None))
self.nameLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Name:", None))
self.shapeLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon shape:", None))
self.shapeCombo.setItemText(0, QCoreApplication.translate("ComponentOptionsDialog", u"rectangle", None))
self.shapeCombo.setItemText(1, QCoreApplication.translate("ComponentOptionsDialog", u"ellipse", None))
self.iconTextLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon text:", None))
self.fillLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Fill color:", None))
self.fillEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#dbeafe", None))
self.borderLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Border color:", None))
self.borderEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#303030", None))
self.showSubtreeCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show contained components in the Libraries tree", None))
# retranslateUi

View File

@@ -0,0 +1,450 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'main_window.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
QHeaderView, QLabel, QMainWindow, QMenu,
QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
QSpacerItem, QSplitter, QStackedWidget, QToolBar,
QToolButton, QTreeView, QVBoxLayout, QWidget)
from bedit.workspace.view import GraphWorkspaceView
from . import resources_rc
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1000, 700)
self.actionNew = QAction(MainWindow)
self.actionNew.setObjectName(u"actionNew")
icon = QIcon()
icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon)
self.actionRotateClockwise = QAction(MainWindow)
self.actionRotateClockwise.setObjectName(u"actionRotateClockwise")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRotateClockwise.setIcon(icon1)
self.actionOpen = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon2)
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon3)
self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon4)
self.actionExit = QAction(MainWindow)
self.actionExit.setObjectName(u"actionExit")
self.actionClose = QAction(MainWindow)
self.actionClose.setObjectName(u"actionClose")
self.actionUndo = QAction(MainWindow)
self.actionUndo.setObjectName(u"actionUndo")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon5)
self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon6)
self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon7)
self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon8)
self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon9)
self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll")
self.actionDelete = QAction(MainWindow)
self.actionDelete.setObjectName(u"actionDelete")
self.actionAbout = QAction(MainWindow)
self.actionAbout.setObjectName(u"actionAbout")
self.actionSettings = QAction(MainWindow)
self.actionSettings.setObjectName(u"actionSettings")
self.actionAboutQt = QAction(MainWindow)
self.actionAboutQt.setObjectName(u"actionAboutQt")
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.workspaceLayout = QHBoxLayout(self.centralwidget)
self.workspaceLayout.setSpacing(0)
self.workspaceLayout.setObjectName(u"workspaceLayout")
self.workspaceLayout.setContentsMargins(0, 0, 0, 0)
self.workspaceSplitter = QSplitter(self.centralwidget)
self.workspaceSplitter.setObjectName(u"workspaceSplitter")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.workspaceSplitter.sizePolicy().hasHeightForWidth())
self.workspaceSplitter.setSizePolicy(sizePolicy)
self.workspaceSplitter.setOrientation(Qt.Orientation.Horizontal)
self.workspaceSplitter.setChildrenCollapsible(False)
self.leftDockHost = QMainWindow(self.workspaceSplitter)
self.leftDockHost.setObjectName(u"leftDockHost")
self.leftDockHost.setMinimumSize(QSize(220, 0))
self.panel_libraries = QDockWidget(self.leftDockHost)
self.panel_libraries.setObjectName(u"panel_libraries")
self.panel_libraries.setMinimumSize(QSize(220, 91))
self.dockWidgetContents = QWidget()
self.dockWidgetContents.setObjectName(u"dockWidgetContents")
self.librariesLayout = QVBoxLayout(self.dockWidgetContents)
self.librariesLayout.setSpacing(0)
self.librariesLayout.setObjectName(u"librariesLayout")
self.librariesLayout.setContentsMargins(0, 0, 0, 0)
self.treeView = QTreeView(self.dockWidgetContents)
self.treeView.setObjectName(u"treeView")
self.treeView.setAlternatingRowColors(True)
self.treeView.setUniformRowHeights(True)
self.librariesLayout.addWidget(self.treeView)
self.panel_libraries.setWidget(self.dockWidgetContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_libraries)
self.panel_document = QDockWidget(self.leftDockHost)
self.panel_document.setObjectName(u"panel_document")
self.panel_document.setMinimumSize(QSize(220, 91))
self.documentDockContents = QWidget()
self.documentDockContents.setObjectName(u"documentDockContents")
self.documentPanelLayout = QVBoxLayout(self.documentDockContents)
self.documentPanelLayout.setSpacing(0)
self.documentPanelLayout.setObjectName(u"documentPanelLayout")
self.documentPanelLayout.setContentsMargins(0, 0, 0, 0)
self.documentTreeView = QTreeView(self.documentDockContents)
self.documentTreeView.setObjectName(u"documentTreeView")
self.documentTreeView.setAlternatingRowColors(True)
self.documentTreeView.setUniformRowHeights(True)
self.documentPanelLayout.addWidget(self.documentTreeView)
self.panel_document.setWidget(self.documentDockContents)
self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_document)
self.workspaceSplitter.addWidget(self.leftDockHost)
self.workspace = QWidget(self.workspaceSplitter)
self.workspace.setObjectName(u"workspace")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy1.setHorizontalStretch(1)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth())
self.workspace.setSizePolicy(sizePolicy1)
self.workspaceEditorLayout = QVBoxLayout(self.workspace)
self.workspaceEditorLayout.setSpacing(0)
self.workspaceEditorLayout.setObjectName(u"workspaceEditorLayout")
self.workspaceEditorLayout.setContentsMargins(0, 0, 0, 0)
self.workspaceHeader = QFrame(self.workspace)
self.workspaceHeader.setObjectName(u"workspaceHeader")
self.workspaceHeader.setMinimumSize(QSize(0, 34))
self.workspaceHeader.setMaximumSize(QSize(16777215, 34))
self.workspaceHeader.setFrameShape(QFrame.Shape.StyledPanel)
self.workspaceHeaderLayout = QHBoxLayout(self.workspaceHeader)
self.workspaceHeaderLayout.setObjectName(u"workspaceHeaderLayout")
self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2)
self.navigateUpButton = QToolButton(self.workspaceHeader)
self.navigateUpButton.setObjectName(u"navigateUpButton")
self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
self.graphBreadcrumbLabel = QLabel(self.workspaceHeader)
self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel")
self.workspaceHeaderLayout.addWidget(self.graphBreadcrumbLabel)
self.workspaceModeLabel = QLabel(self.workspaceHeader)
self.workspaceModeLabel.setObjectName(u"workspaceModeLabel")
self.workspaceHeaderLayout.addWidget(self.workspaceModeLabel)
self.workspaceHeaderSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.workspaceHeaderLayout.addItem(self.workspaceHeaderSpacer)
self.applyJsonButton = QPushButton(self.workspaceHeader)
self.applyJsonButton.setObjectName(u"applyJsonButton")
self.applyJsonButton.setVisible(False)
self.workspaceHeaderLayout.addWidget(self.applyJsonButton)
self.pointerToolButton = QToolButton(self.workspaceHeader)
self.pointerToolButton.setObjectName(u"pointerToolButton")
self.pointerToolButton.setCheckable(True)
self.pointerToolButton.setChecked(True)
self.pointerToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.pointerToolButton)
self.inputToolButton = QToolButton(self.workspaceHeader)
self.inputToolButton.setObjectName(u"inputToolButton")
self.inputToolButton.setCheckable(True)
self.inputToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.inputToolButton)
self.outputToolButton = QToolButton(self.workspaceHeader)
self.outputToolButton.setObjectName(u"outputToolButton")
self.outputToolButton.setCheckable(True)
self.outputToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.outputToolButton)
self.workspaceEditorLayout.addWidget(self.workspaceHeader)
self.workspaceStack = QStackedWidget(self.workspace)
self.workspaceStack.setObjectName(u"workspaceStack")
self.graphPage = QWidget()
self.graphPage.setObjectName(u"graphPage")
self.graphPageLayout = QVBoxLayout(self.graphPage)
self.graphPageLayout.setObjectName(u"graphPageLayout")
self.graphPageLayout.setContentsMargins(0, 0, 0, 0)
self.graphView = GraphWorkspaceView(self.graphPage)
self.graphView.setObjectName(u"graphView")
self.graphPageLayout.addWidget(self.graphView)
self.workspaceStack.addWidget(self.graphPage)
self.jsonPage = QWidget()
self.jsonPage.setObjectName(u"jsonPage")
self.jsonPageLayout = QVBoxLayout(self.jsonPage)
self.jsonPageLayout.setObjectName(u"jsonPageLayout")
self.jsonPageLayout.setContentsMargins(0, 0, 0, 0)
self.jsonEditor = QPlainTextEdit(self.jsonPage)
self.jsonEditor.setObjectName(u"jsonEditor")
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.jsonPageLayout.addWidget(self.jsonEditor)
self.workspaceStack.addWidget(self.jsonPage)
self.emptyPage = QWidget()
self.emptyPage.setObjectName(u"emptyPage")
self.emptyPageLayout = QVBoxLayout(self.emptyPage)
self.emptyPageLayout.setObjectName(u"emptyPageLayout")
self.emptyWorkspaceLabel = QLabel(self.emptyPage)
self.emptyWorkspaceLabel.setObjectName(u"emptyWorkspaceLabel")
self.emptyWorkspaceLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyPageLayout.addWidget(self.emptyWorkspaceLabel)
self.workspaceStack.addWidget(self.emptyPage)
self.workspaceEditorLayout.addWidget(self.workspaceStack)
self.workspaceSplitter.addWidget(self.workspace)
self.workspaceLayout.addWidget(self.workspaceSplitter)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1000, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuEdit = QMenu(self.menubar)
self.menuEdit.setObjectName(u"menuEdit")
self.menuView = QMenu(self.menubar)
self.menuView.setObjectName(u"menuView")
self.menuPanels = QMenu(self.menuView)
self.menuPanels.setObjectName(u"menuPanels")
self.menuToolbars = QMenu(self.menuView)
self.menuToolbars.setObjectName(u"menuToolbars")
self.menuHelp = QMenu(self.menubar)
self.menuHelp.setObjectName(u"menuHelp")
MainWindow.setMenuBar(self.menubar)
self.fileToolbar = QToolBar(MainWindow)
self.fileToolbar.setObjectName(u"fileToolbar")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.fileToolbar.sizePolicy().hasHeightForWidth())
self.fileToolbar.setSizePolicy(sizePolicy2)
self.fileToolbar.setMinimumSize(QSize(0, 40))
self.fileToolbar.setMaximumSize(QSize(16777215, 40))
self.fileToolbar.setIconSize(QSize(24, 24))
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.fileToolbar)
self.editToolbar = QToolBar(MainWindow)
self.editToolbar.setObjectName(u"editToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.editToolbar)
self.transformToolbar = QToolBar(MainWindow)
self.transformToolbar.setObjectName(u"transformToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.transformToolbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionNew)
self.menuFile.addAction(self.actionOpen)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionClose)
self.menuFile.addAction(self.actionExit)
self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionCopy)
self.menuEdit.addAction(self.actionCut)
self.menuEdit.addAction(self.actionPaste)
self.menuEdit.addAction(self.actionDelete)
self.menuEdit.addAction(self.actionSelectAll)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionSettings)
self.menuView.addAction(self.menuPanels.menuAction())
self.menuView.addAction(self.menuToolbars.menuAction())
self.menuHelp.addAction(self.actionAbout)
self.menuHelp.addAction(self.actionAboutQt)
self.fileToolbar.addAction(self.actionNew)
self.fileToolbar.addAction(self.actionOpen)
self.fileToolbar.addAction(self.actionSave)
self.fileToolbar.addAction(self.actionSaveAs)
self.editToolbar.addAction(self.actionUndo)
self.editToolbar.addAction(self.actionRedo)
self.editToolbar.addAction(self.actionCopy)
self.editToolbar.addAction(self.actionCut)
self.editToolbar.addAction(self.actionPaste)
self.transformToolbar.addAction(self.actionRotateClockwise)
self.retranslateUi(MainWindow)
self.workspaceStack.setCurrentIndex(0)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"BEdit", None))
self.actionNew.setText(QCoreApplication.translate("MainWindow", u"&New", None))
#if QT_CONFIG(statustip)
self.actionNew.setStatusTip(QCoreApplication.translate("MainWindow", u"Create a new document", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionNew.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionRotateClockwise.setText(QCoreApplication.translate("MainWindow", u"Rotate Clockwise", None))
#if QT_CONFIG(tooltip)
self.actionRotateClockwise.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise by 90 degrees", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionRotateClockwise.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+R", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None))
#if QT_CONFIG(statustip)
self.actionOpen.setStatusTip(QCoreApplication.translate("MainWindow", u"Open a document", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionOpen.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionSave.setText(QCoreApplication.translate("MainWindow", u"&Save", None))
#if QT_CONFIG(statustip)
self.actionSave.setStatusTip(QCoreApplication.translate("MainWindow", u"Save the current document", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionSave.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionSaveAs.setText(QCoreApplication.translate("MainWindow", u"Save &As\u2026", None))
#if QT_CONFIG(shortcut)
self.actionSaveAs.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.actionClose.setText(QCoreApplication.translate("MainWindow", u"&Close Document", None))
#if QT_CONFIG(shortcut)
self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+W", None))
#endif // QT_CONFIG(shortcut)
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None))
#if QT_CONFIG(shortcut)
self.actionUndo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Z", None))
#endif // QT_CONFIG(shortcut)
self.actionRedo.setText(QCoreApplication.translate("MainWindow", u"&Redo", None))
#if QT_CONFIG(shortcut)
self.actionRedo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Y", None))
#endif // QT_CONFIG(shortcut)
self.actionCut.setText(QCoreApplication.translate("MainWindow", u"Cu&t", None))
#if QT_CONFIG(shortcut)
self.actionCut.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+X", None))
#endif // QT_CONFIG(shortcut)
self.actionCopy.setText(QCoreApplication.translate("MainWindow", u"&Copy", None))
#if QT_CONFIG(shortcut)
self.actionCopy.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+C", None))
#endif // QT_CONFIG(shortcut)
self.actionPaste.setText(QCoreApplication.translate("MainWindow", u"&Paste", None))
#if QT_CONFIG(shortcut)
self.actionPaste.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+V", None))
#endif // QT_CONFIG(shortcut)
self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None))
#if QT_CONFIG(shortcut)
self.actionSelectAll.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+A", None))
#endif // QT_CONFIG(shortcut)
self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"&Delete", None))
#if QT_CONFIG(shortcut)
self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None))
#endif // QT_CONFIG(shortcut)
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None))
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None))
#if QT_CONFIG(statustip)
self.actionSettings.setStatusTip(QCoreApplication.translate("MainWindow", u"Configure BEdit", None))
#endif // QT_CONFIG(statustip)
self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None))
self.panel_libraries.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None))
self.panel_document.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document", None))
self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
#if QT_CONFIG(tooltip)
self.navigateUpButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the containing graph", None))
#endif // QT_CONFIG(tooltip)
self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None))
self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None))
self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None))
self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None))
self.inputToolButton.setText(QCoreApplication.translate("MainWindow", u"Input", None))
#if QT_CONFIG(tooltip)
self.inputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface input", None))
#endif // QT_CONFIG(tooltip)
self.outputToolButton.setText(QCoreApplication.translate("MainWindow", u"Output", None))
#if QT_CONFIG(tooltip)
self.outputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface output", None))
#endif // QT_CONFIG(tooltip)
self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None))
self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None))
self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))
self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None))
self.menuView.setTitle(QCoreApplication.translate("MainWindow", u"&View", None))
self.menuPanels.setTitle(QCoreApplication.translate("MainWindow", u"&Panels", None))
self.menuToolbars.setTitle(QCoreApplication.translate("MainWindow", u"&Toolbars", None))
self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None))
self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None))
self.transformToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Transform", None))
# retranslateUi

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'settings_dialog.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox,
QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout,
QWidget)
class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog):
if not SettingsDialog.objectName():
SettingsDialog.setObjectName(u"SettingsDialog")
SettingsDialog.resize(480, 300)
SettingsDialog.setModal(True)
self.dialogLayout = QVBoxLayout(SettingsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.settingsTabs = QTabWidget(SettingsDialog)
self.settingsTabs.setObjectName(u"settingsTabs")
self.generalTab = QWidget()
self.generalTab.setObjectName(u"generalTab")
self.generalLayout = QVBoxLayout(self.generalTab)
self.generalLayout.setObjectName(u"generalLayout")
self.autosaveGroupBox = QGroupBox(self.generalTab)
self.autosaveGroupBox.setObjectName(u"autosaveGroupBox")
self.autosaveGroupBox.setCheckable(True)
self.autosaveGroupBox.setChecked(False)
self.autosaveLayout = QFormLayout(self.autosaveGroupBox)
self.autosaveLayout.setObjectName(u"autosaveLayout")
self.autosaveIntervalSpinBox = QSpinBox(self.autosaveGroupBox)
self.autosaveIntervalSpinBox.setObjectName(u"autosaveIntervalSpinBox")
self.autosaveIntervalSpinBox.setMinimum(1)
self.autosaveIntervalSpinBox.setMaximum(120)
self.autosaveIntervalSpinBox.setValue(5)
self.autosaveLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.autosaveIntervalSpinBox)
self.generalLayout.addWidget(self.autosaveGroupBox)
self.generalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "")
self.librariesTab = QWidget()
self.librariesTab.setObjectName(u"librariesTab")
self.librariesTabLayout = QVBoxLayout(self.librariesTab)
self.librariesTabLayout.setObjectName(u"librariesTabLayout")
self.libraryPathsLabel = QLabel(self.librariesTab)
self.libraryPathsLabel.setObjectName(u"libraryPathsLabel")
self.libraryPathsLabel.setWordWrap(True)
self.librariesTabLayout.addWidget(self.libraryPathsLabel)
self.libraryPathsList = QListWidget(self.librariesTab)
self.libraryPathsList.setObjectName(u"libraryPathsList")
self.librariesTabLayout.addWidget(self.libraryPathsList)
self.libraryPathButtonsLayout = QHBoxLayout()
self.libraryPathButtonsLayout.setObjectName(u"libraryPathButtonsLayout")
self.addLibraryFileButton = QPushButton(self.librariesTab)
self.addLibraryFileButton.setObjectName(u"addLibraryFileButton")
self.libraryPathButtonsLayout.addWidget(self.addLibraryFileButton)
self.addLibraryFolderButton = QPushButton(self.librariesTab)
self.addLibraryFolderButton.setObjectName(u"addLibraryFolderButton")
self.libraryPathButtonsLayout.addWidget(self.addLibraryFolderButton)
self.removeLibraryPathButton = QPushButton(self.librariesTab)
self.removeLibraryPathButton.setObjectName(u"removeLibraryPathButton")
self.libraryPathButtonsLayout.addWidget(self.removeLibraryPathButton)
self.libraryButtonsSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.libraryPathButtonsLayout.addItem(self.libraryButtonsSpacer)
self.librariesTabLayout.addLayout(self.libraryPathButtonsLayout)
self.settingsTabs.addTab(self.librariesTab, "")
self.dialogLayout.addWidget(self.settingsTabs)
self.buttonBox = QDialogButtonBox(SettingsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setOrientation(Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(SettingsDialog)
self.buttonBox.accepted.connect(SettingsDialog.accept)
self.buttonBox.rejected.connect(SettingsDialog.reject)
self.settingsTabs.setCurrentIndex(0)
QMetaObject.connectSlotsByName(SettingsDialog)
# setupUi
def retranslateUi(self, SettingsDialog):
SettingsDialog.setWindowTitle(QCoreApplication.translate("SettingsDialog", u"Settings", None))
self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None))
self.autosaveIntervalSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" minutes", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None))
self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None))
self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None))
self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.librariesTab), QCoreApplication.translate("SettingsDialog", u"Libraries", None))
# retranslateUi

View File

@@ -0,0 +1,4 @@
from bedit.workspace.view import GraphWorkspaceView
__all__ = ["GraphWorkspaceView"]

View File

@@ -0,0 +1,547 @@
import json
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor,
QDragEnterEvent,
QDropEvent,
QMouseEvent,
QPainter,
QPainterPath,
QPen,
QTransform,
)
from PySide6.QtWidgets import (
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QGraphicsView,
QApplication,
QMenu,
QStyleOptionGraphicsItem,
QWidget,
)
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Port
from bedit.library.tree_model import COMPONENT_MIME_TYPE
SELECTION_MIME_TYPE = "application/x-bedit-selection"
class ConnectionPortItem(QGraphicsEllipseItem):
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
super().__init__(-6, -6, 12, 12, parent)
self.endpoint = endpoint
self.role = role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#303030"), 1.5))
self.setZValue(2)
self.setToolTip(label)
class ComponentGraphicsItem(QGraphicsObject):
WIDTH = 120.0
HEIGHT = 72.0
def __init__(self, component: Component, controller: DocumentController) -> None:
super().__init__()
self.component_id = component.id
self.component = component
self.controller = controller
self.drag_start = QPointF()
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
self.setTransformOriginPoint(self.WIDTH / 2, self.HEIGHT / 2)
self.setRotation(component.rotation)
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
result = {}
spacing = self.HEIGHT / (len(ports) + 1)
for index, port in enumerate(ports, start=1):
endpoint = Endpoint(block=self.component_id, port=port.id)
item = ConnectionPortItem(endpoint, role, port.name, self)
item.setPos(x, spacing * index)
result[port.id] = item
return result
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
icon = self.component.icon
fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill)
painter.setBrush(fill)
painter.setPen(QPen(QColor(icon.border), 1.5))
if icon.shape == "ellipse":
painter.drawEllipse(self.boundingRect())
else:
painter.drawRoundedRect(self.boundingRect(), 5, 5)
painter.setPen(QColor("#202020"))
painter.drawText(
self.boundingRect(),
Qt.AlignmentFlag.AlignCenter,
icon.text or self.component.name,
)
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.controller.activate_component(self.component_id)
event.accept()
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
scene = self.scene()
if scene is not None:
scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options_action = menu.addAction("Component Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.componentOptionsRequested.emit(self.component_id)
event.accept()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_component(self.component_id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_block(self.component_id)
return super().itemChange(change, value)
class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0
HEIGHT = 36.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__()
self.port = port
self.direction = direction
self.controller = controller
self.drag_start = QPointF()
role = "source" if direction == "input" else "target"
self.connection_port = ConnectionPortItem(
Endpoint(interface=port.id), role, port.name, self
)
connection_x = self.WIDTH if direction == "input" else 0.0
self.connection_port.setPos(connection_x, self.HEIGHT / 2)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setToolTip(f"Component {direction}: {port.name}")
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
painter.setBrush(QColor("#e5e7eb"))
painter.setPen(QPen(QColor("#4b5563"), 1.5))
painter.drawRoundedRect(self.boundingRect(), 4, 4)
painter.setPen(QColor("#202020"))
marker = "IN" if self.direction == "input" else "OUT"
painter.drawText(
self.boundingRect().adjusted(8, 0, -8, 0),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
f"{marker} {self.port.name}",
)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.portOptionsRequested.emit(self.port.id, self.direction)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_interface_port(self.port.id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_interface(self.port.id)
return super().itemChange(change, value)
class ConnectionGraphicsItem(QGraphicsPathItem):
def __init__(self, connection_id: str, name: str = "") -> None:
super().__init__()
self.connection_id = connection_id
self.name = name
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
self.setToolTip(name or "Connection")
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction("Connection Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.connectionOptionsRequested.emit(self.connection_id)
event.accept()
def itemChange(self, change, value): # noqa: N802
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self._update_pen()
return result
def _update_pen(self) -> None:
self.setPen(
QPen(
QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"),
4.0 if self.isSelected() else 2.5,
)
)
class GraphScene(QGraphicsScene):
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
def __init__(self, controller: DocumentController, parent=None) -> None:
super().__init__(parent)
self.controller = controller
self.component_items: dict[str, ComponentGraphicsItem] = {}
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
self.setSceneRect(-2000, -2000, 4000, 4000)
controller.documentReset.connect(self.rebuild)
controller.activeGraphChanged.connect(self.rebuild)
controller.componentMoved.connect(self.set_component_position)
controller.componentRotated.connect(self.set_component_rotation)
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.component_items.clear()
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.pending_source = None
owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
for port in owner.inputs:
item = InterfaceTerminalItem(port, "input", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.input_items[port.id] = item
for port in owner.outputs:
item = InterfaceTerminalItem(port, "output", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.output_items[port.id] = item
for component in owner.graph.blocks.values():
item = ComponentGraphicsItem(component, self.controller)
self.addItem(item)
item.setPos(component.x, component.y)
self.component_items[component.id] = item
for connection in owner.graph.connections.values():
item = ConnectionGraphicsItem(connection.id, connection.name)
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
def set_component_position(self, component_id: str, position: QPointF) -> None:
item = self.component_items.get(component_id)
if item is not None and item.pos() != position:
item.setPos(position)
def set_component_rotation(self, component_id: str, rotation: float) -> None:
item = self.component_items.get(component_id)
if item is not None:
item.setRotation(rotation)
self.update_connections_for_block(component_id)
def update_connections_for_block(self, component_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if component_id in (connection.source.block, connection.target.block):
self.update_connection(connection.id)
def update_connections_for_interface(self, port_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if port_id in (connection.source.interface, connection.target.interface):
self.update_connection(connection.id)
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
painter.fillRect(rect, QColor("#e4e4e4"))
if self.controller.document is None or self.controller.active_component is None:
return
spacing = 32
left = int(rect.left()) - (int(rect.left()) % spacing)
top = int(rect.top()) - (int(rect.top()) % spacing)
painter.setPen(QPen(QColor("#b8b8b8"), 1))
for x in range(left, int(rect.right()) + spacing, spacing):
for y in range(top, int(rect.bottom()) + spacing, spacing):
painter.drawPoint(x, y)
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
if endpoint.interface is not None:
terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface)
return terminal.connection_port if terminal else None
component = self.component_items.get(endpoint.block or "")
if component is None:
return None
ports = component.output_ports if role == "source" else component.input_ports
return ports.get(endpoint.port or "")
def update_connection(self, connection_id: str) -> None:
connection = self.controller.active_graph.connections.get(connection_id)
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
source = self._endpoint_item(connection.source, "source")
target = self._endpoint_item(connection.target, "target")
if source is None or target is None:
return
start, end = source.scenePos(), target.scenePos()
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
path = QPainterPath(start)
path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end)
graphics.setPath(path)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
item = self.itemAt(event.scenePos(), QTransform())
if isinstance(item, ConnectionPortItem):
if item.role == "source":
self._clear_pending_source()
self.pending_source = item
item.setBrush(QColor("#f5b642"))
elif self.pending_source is not None:
if self.pending_source.endpoint != item.endpoint:
self.controller.connect(self.pending_source.endpoint, item.endpoint)
self._clear_pending_source()
event.accept()
return
self._clear_pending_source()
super().mousePressEvent(event)
def _clear_pending_source(self) -> None:
if self.pending_source is not None:
self.pending_source.setBrush(QColor("#ffffff"))
self.pending_source = None
class GraphWorkspaceView(QGraphicsView):
toolUsed = Signal()
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
selectionAvailabilityChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.controller: DocumentController | None = None
self.tool_mode = "pointer"
self.paste_count = 0
self.setAcceptDrops(True)
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setBackgroundBrush(QColor("#9a9a9a"))
def set_model(self, controller: DocumentController) -> None:
self.controller = controller
scene = GraphScene(controller, self)
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
scene.portOptionsRequested.connect(self.portOptionsRequested)
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
scene.selectionChanged.connect(
lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems()))
)
self.setScene(scene)
def select_all(self) -> None:
scene = self.scene()
if scene is None:
return
for item in scene.items():
if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable:
item.setSelected(True)
def delete_selected(self) -> None:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return
blocks: set[str] = set()
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
blocks.add(item.component_id)
elif isinstance(item, ConnectionGraphicsItem):
connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id)
self.controller.delete_selection(blocks, connections, inputs, outputs)
def has_selected_components(self) -> bool:
scene = self.scene()
return bool(
scene
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def rotate_selected(self) -> None:
if self.controller is None or self.scene() is None:
return
component_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
self.controller.rotate_components(component_ids)
def copy_selection(self) -> bool:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return False
selected_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
if not selected_ids:
return False
graph = self.controller.active_graph
components = [graph.blocks[component_id].to_dict() for component_id in selected_ids]
connections = [
connection.to_dict()
for connection in graph.connections.values()
if connection.source.block in selected_ids and connection.target.block in selected_ids
]
mime_data = QMimeData()
mime_data.setData(
SELECTION_MIME_TYPE,
json.dumps({"components": components, "connections": connections}).encode("utf-8"),
)
QApplication.clipboard().setMimeData(mime_data)
self.paste_count = 0
return True
def cut_selection(self) -> None:
if self.copy_selection():
self.delete_selected()
def paste_selection(self) -> None:
if self.controller is None:
return
mime_data = QApplication.clipboard().mimeData()
if not mime_data.hasFormat(SELECTION_MIME_TYPE):
return
try:
payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8"))
components = [Component.from_dict(item) for item in payload.get("components", [])]
connections = [Connection.from_dict(item) for item in payload.get("connections", [])]
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
return
self.paste_count += 1
new_ids = self.controller.paste_selection(
components,
connections,
QPointF(32 * self.paste_count, 32 * self.paste_count),
)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.clearSelection()
for component_id in new_ids:
item = scene.component_items.get(component_id)
if item is not None:
item.setSelected(True)
def set_tool_mode(self, mode: str) -> None:
self.tool_mode = mode
self.setDragMode(
QGraphicsView.DragMode.RubberBandDrag
if mode == "pointer"
else QGraphicsView.DragMode.NoDrag
)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if (
self.controller is not None
and self.tool_mode in {"input", "output"}
and event.button() == Qt.MouseButton.LeftButton
):
self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint()))
self.toolUsed.emit()
event.accept()
return
super().mousePressEvent(event)
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE):
super().dropEvent(event)
return
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
source = Component.from_dict(data)
self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint()))
event.acceptProposedAction()

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ComponentOptionsDialog</class>
<widget class="QDialog" name="ComponentOptionsDialog">
<property name="geometry"><rect><x>0</x><y>0</y><width>420</width><height>260</height></rect></property>
<property name="windowTitle"><string>Component Options</string></property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<layout class="QFormLayout" name="optionsForm">
<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="shapeLabel"><property name="text"><string>Icon shape:</string></property></widget></item>
<item row="1" column="1"><widget class="QComboBox" name="shapeCombo"><item><property name="text"><string>rectangle</string></property></item><item><property name="text"><string>ellipse</string></property></item></widget></item>
<item row="2" column="0"><widget class="QLabel" name="iconTextLabel"><property name="text"><string>Icon text:</string></property></widget></item>
<item row="2" column="1"><widget class="QLineEdit" name="iconTextEdit"/></item>
<item row="3" column="0"><widget class="QLabel" name="fillLabel"><property name="text"><string>Fill color:</string></property></widget></item>
<item row="3" column="1"><widget class="QLineEdit" name="fillEdit"><property name="placeholderText"><string>#dbeafe</string></property></widget></item>
<item row="4" column="0"><widget class="QLabel" name="borderLabel"><property name="text"><string>Border color:</string></property></widget></item>
<item row="4" column="1"><widget class="QLineEdit" name="borderEdit"><property name="placeholderText"><string>#303030</string></property></widget></item>
<item row="5" column="0" colspan="2"><widget class="QCheckBox" name="showSubtreeCheckBox"><property name="text"><string>Show contained components in the Libraries tree</string></property><property name="checked"><bool>true</bool></property></widget></item>
</layout>
</item>
<item><spacer name="optionsSpacer"><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="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
</layout>
</widget>
<resources/>
<connections>
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>ComponentOptionsDialog</receiver><slot>accept()</slot><hints/></connection>
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>ComponentOptionsDialog</receiver><slot>reject()</slot><hints/></connection>
</connections>
</ui>

494
BEdit/ui/main_window.ui Normal file
View File

@@ -0,0 +1,494 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>700</height>
</rect>
</property>
<property name="windowTitle">
<string>BEdit</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="workspaceLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QSplitter" name="workspaceSplitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QMainWindow" name="leftDockHost">
<property name="minimumSize">
<size>
<width>220</width>
<height>0</height>
</size>
</property>
<widget class="QDockWidget" name="panel_libraries">
<property name="minimumSize">
<size>
<width>220</width>
<height>91</height>
</size>
</property>
<property name="windowTitle">
<string>Libraries</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="librariesLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QTreeView" name="treeView">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="uniformRowHeights">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="panel_document">
<property name="minimumSize">
<size><width>220</width><height>91</height></size>
</property>
<property name="windowTitle"><string>Document</string></property>
<attribute name="dockWidgetArea"><number>1</number></attribute>
<widget class="QWidget" name="documentDockContents">
<layout class="QVBoxLayout" name="documentPanelLayout">
<property name="spacing"><number>0</number></property>
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QTreeView" name="documentTreeView">
<property name="alternatingRowColors"><bool>true</bool></property>
<property name="uniformRowHeights"><bool>true</bool></property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
<widget class="QWidget" name="workspace">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="workspaceEditorLayout">
<property name="spacing"><number>0</number></property>
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QFrame" name="workspaceHeader">
<property name="minimumSize"><size><width>0</width><height>34</height></size></property>
<property name="maximumSize"><size><width>16777215</width><height>34</height></size></property>
<property name="frameShape"><enum>QFrame::Shape::StyledPanel</enum></property>
<layout class="QHBoxLayout" name="workspaceHeaderLayout">
<property name="leftMargin"><number>6</number></property>
<property name="topMargin"><number>2</number></property>
<property name="rightMargin"><number>6</number></property>
<property name="bottomMargin"><number>2</number></property>
<item><widget class="QToolButton" name="navigateUpButton"><property name="text"><string>Up</string></property><property name="toolTip"><string>Open the containing graph</string></property></widget></item>
<item><widget class="QLabel" name="graphBreadcrumbLabel"><property name="text"><string>Untitled</string></property></widget></item>
<item><widget class="QLabel" name="workspaceModeLabel"><property name="text"><string>Graph</string></property></widget></item>
<item><spacer name="workspaceHeaderSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QPushButton" name="applyJsonButton"><property name="text"><string>Apply JSON</string></property><property name="visible"><bool>false</bool></property></widget></item>
<item><widget class="QToolButton" name="pointerToolButton"><property name="text"><string>Pointer</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
<item><widget class="QToolButton" name="inputToolButton"><property name="text"><string>Input</string></property><property name="toolTip"><string>Add an interface input</string></property><property name="checkable"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
<item><widget class="QToolButton" name="outputToolButton"><property name="text"><string>Output</string></property><property name="toolTip"><string>Add an interface output</string></property><property name="checkable"><bool>true</bool></property><property name="autoExclusive"><bool>true</bool></property></widget></item>
</layout>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="workspaceStack">
<property name="currentIndex"><number>0</number></property>
<widget class="QWidget" name="graphPage">
<layout class="QVBoxLayout" name="graphPageLayout">
<property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="GraphWorkspaceView" name="graphView"/></item>
</layout>
</widget>
<widget class="QWidget" name="jsonPage">
<layout class="QVBoxLayout" name="jsonPageLayout">
<property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QPlainTextEdit" name="jsonEditor"><property name="lineWrapMode"><enum>QPlainTextEdit::LineWrapMode::NoWrap</enum></property><property name="placeholderText"><string>Component JSON</string></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="emptyPage">
<layout class="QVBoxLayout" name="emptyPageLayout">
<item>
<widget class="QLabel" name="emptyWorkspaceLabel">
<property name="text"><string>No document open</string></property>
<property name="alignment"><set>Qt::AlignmentFlag::AlignCenter</set></property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>24</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>&amp;File</string>
</property>
<addaction name="actionNew"/>
<addaction name="actionOpen"/>
<addaction name="separator"/>
<addaction name="actionSave"/>
<addaction name="actionSaveAs"/>
<addaction name="separator"/>
<addaction name="actionClose"/>
<addaction name="actionExit"/>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>&amp;Edit</string>
</property>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="separator"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
<addaction name="actionDelete"/>
<addaction name="actionSelectAll"/>
<addaction name="separator"/>
<addaction name="actionSettings"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
<string>&amp;View</string>
</property>
<widget class="QMenu" name="menuPanels">
<property name="title">
<string>&amp;Panels</string>
</property>
</widget>
<widget class="QMenu" name="menuToolbars">
<property name="title">
<string>&amp;Toolbars</string>
</property>
</widget>
<addaction name="menuPanels"/>
<addaction name="menuToolbars"/>
</widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>&amp;Help</string>
</property>
<addaction name="actionAbout"/>
<addaction name="actionAboutQt"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
<addaction name="menuView"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QToolBar" name="fileToolbar">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>40</height>
</size>
</property>
<property name="windowTitle">
<string>File</string>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionNew"/>
<addaction name="actionOpen"/>
<addaction name="actionSave"/>
<addaction name="actionSaveAs"/>
</widget>
<widget class="QToolBar" name="editToolbar">
<property name="windowTitle">
<string>Edit</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
</widget>
<widget class="QToolBar" name="transformToolbar">
<property name="windowTitle"><string>Transform</string></property>
<attribute name="toolBarArea"><enum>TopToolBarArea</enum></attribute>
<attribute name="toolBarBreak"><bool>false</bool></attribute>
<addaction name="actionRotateClockwise"/>
</widget>
<action name="actionNew">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/document-new.png</normaloff>:/icons/icons/document-new.png</iconset>
</property>
<property name="text">
<string>&amp;New</string>
</property>
<property name="statusTip">
<string>Create a new document</string>
</property>
<property name="shortcut">
<string>Ctrl+N</string>
</property>
</action>
<action name="actionRotateClockwise">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/transform-rotate.png</normaloff>:/icons/icons/transform-rotate.png</iconset>
</property>
<property name="text"><string>Rotate Clockwise</string></property>
<property name="toolTip"><string>Rotate selected blocks clockwise by 90 degrees</string></property>
<property name="shortcut"><string>Ctrl+R</string></property>
</action>
<action name="actionOpen">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
</property>
<property name="text">
<string>&amp;Open…</string>
</property>
<property name="statusTip">
<string>Open a document</string>
</property>
<property name="shortcut">
<string>Ctrl+O</string>
</property>
</action>
<action name="actionSave">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
</property>
<property name="text">
<string>&amp;Save</string>
</property>
<property name="statusTip">
<string>Save the current document</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="actionSaveAs">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/document-save-as.png</normaloff>:/icons/icons/document-save-as.png</iconset>
</property>
<property name="text">
<string>Save &amp;As…</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="actionExit">
<property name="text">
<string>E&amp;xit</string>
</property>
<property name="shortcut">
<string>Ctrl+Q</string>
</property>
</action>
<action name="actionClose">
<property name="text"><string>&amp;Close Document</string></property>
<property name="shortcut"><string>Ctrl+W</string></property>
</action>
<action name="actionUndo">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
</property>
<property name="text">
<string>&amp;Undo</string>
</property>
<property name="shortcut">
<string>Ctrl+Z</string>
</property>
</action>
<action name="actionRedo">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
</property>
<property name="text">
<string>&amp;Redo</string>
</property>
<property name="shortcut">
<string>Ctrl+Y</string>
</property>
</action>
<action name="actionCut">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/edit-cut.png</normaloff>:/icons/icons/edit-cut.png</iconset>
</property>
<property name="text">
<string>Cu&amp;t</string>
</property>
<property name="shortcut">
<string>Ctrl+X</string>
</property>
</action>
<action name="actionCopy">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/edit-copy.png</normaloff>:/icons/icons/edit-copy.png</iconset>
</property>
<property name="text">
<string>&amp;Copy</string>
</property>
<property name="shortcut">
<string>Ctrl+C</string>
</property>
</action>
<action name="actionPaste">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/edit-paste.png</normaloff>:/icons/icons/edit-paste.png</iconset>
</property>
<property name="text">
<string>&amp;Paste</string>
</property>
<property name="shortcut">
<string>Ctrl+V</string>
</property>
</action>
<action name="actionSelectAll">
<property name="text">
<string>Select &amp;All</string>
</property>
<property name="shortcut">
<string>Ctrl+A</string>
</property>
</action>
<action name="actionDelete">
<property name="text"><string>&amp;Delete</string></property>
<property name="shortcut"><string>Del</string></property>
</action>
<action name="actionAbout">
<property name="text">
<string>&amp;About BEdit</string>
</property>
</action>
<action name="actionSettings">
<property name="text">
<string>&amp;Settings…</string>
</property>
<property name="statusTip">
<string>Configure BEdit</string>
</property>
</action>
<action name="actionAboutQt">
<property name="text">
<string>About &amp;Qt</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>GraphWorkspaceView</class>
<extends>QGraphicsView</extends>
<header>bedit.workspace.view</header>
</customwidget>
</customwidgets>
<resources>
<include location="../resources/resources.qrc"/>
</resources>
<connections/>
</ui>

170
BEdit/ui/settings_dialog.ui Normal file
View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SettingsDialog</class>
<widget class="QDialog" name="SettingsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>480</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Settings</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<widget class="QTabWidget" name="settingsTabs">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="generalTab">
<attribute name="title">
<string>General</string>
</attribute>
<layout class="QVBoxLayout" name="generalLayout">
<item>
<widget class="QGroupBox" name="autosaveGroupBox">
<property name="title">
<string>Automatic saving</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
<layout class="QFormLayout" name="autosaveLayout">
<item row="0" column="1">
<widget class="QSpinBox" name="autosaveIntervalSpinBox">
<property name="suffix">
<string> minutes</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>120</number>
</property>
<property name="value">
<number>5</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="generalSpacer">
<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>
</layout>
</widget>
<widget class="QWidget" name="librariesTab">
<attribute name="title">
<string>Libraries</string>
</attribute>
<layout class="QVBoxLayout" name="librariesTabLayout">
<item>
<widget class="QLabel" name="libraryPathsLabel">
<property name="text">
<string>Load library JSON files from these files or folders at startup:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="libraryPathsList"/>
</item>
<item>
<layout class="QHBoxLayout" name="libraryPathButtonsLayout">
<item>
<widget class="QPushButton" name="addLibraryFileButton">
<property name="text"><string>Add File…</string></property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addLibraryFolderButton">
<property name="text"><string>Add Folder…</string></property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeLibraryPathButton">
<property name="text"><string>Remove</string></property>
</widget>
</item>
<item>
<spacer name="libraryButtonsSpacer">
<property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property>
<property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>SettingsDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>390</x>
<y>275</y>
</hint>
<hint type="destinationlabel">
<x>240</x>
<y>150</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>SettingsDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>390</x>
<y>275</y>
</hint>
<hint type="destinationlabel">
<x>240</x>
<y>150</y>
</hint>
</hints>
</connection>
</connections>
</ui>