Compare commits

...

31 Commits

Author SHA1 Message Date
8a6203d01e Fixed some bugs 2026-07-22 19:16:56 +02:00
2d49f65ec4 Fixed some bugs 2026-07-22 18:13:13 +02:00
c918a6a428 causality inference done 2026-07-22 18:04:07 +02:00
32154386e8 Start with causality inference 2026-07-22 13:20:49 +02:00
09248421d0 Bond graph ports and drawing added 2026-07-22 12:24:58 +02:00
0c578af85d port and param editor in and outside of text editor the same 2026-07-22 11:45:43 +02:00
edefb23edc More types and units 2026-07-22 11:38:07 +02:00
48ac9e2f59 proper modelica text model editing 2026-07-21 17:16:06 +02:00
2f6487e510 Added direct graph navigation without activating Matplotlib toolbar modes:
Mouse wheel zooms in and out around the cursor.
Left-click dragging pans the graph.
Matplotlib toolbar modes still work and take precedence when activated.
The existing Home button can reset the view.
2026-07-21 15:29:58 +02:00
eb8f77ce64 better signal graphing 2026-07-21 15:24:24 +02:00
35d933ccbb Added signal graphing 2026-07-21 15:14:12 +02:00
edd7bb98f2 Saving sim results 2026-07-21 14:40:03 +02:00
ddc004dee5 sim window save/load 2026-07-21 14:01:55 +02:00
cdfc891980 Start of a sim window 2026-07-21 13:54:57 +02:00
6fb2478589 basic runner setup changed 2026-07-21 13:21:52 +02:00
dc770a0886 new runner layout, still todo 2026-07-20 22:03:43 +02:00
38e3d7aae9 Added run button and parameter dialog 2026-07-20 18:46:59 +02:00
4cfe7c3a0e Fixed om emission 2026-07-20 18:17:45 +02:00
d368b36b8c basic openmodelica model emitting 2026-07-20 17:34:31 +02:00
42bf09610b Added binary file format 2026-07-20 15:39:34 +02:00
9ed8e371ef added stub simulation entry and logs 2026-07-20 15:26:18 +02:00
495edd42f4 Added array ports and junctions 2026-07-20 15:00:06 +02:00
a067c85994 new text mode editor 2026-07-20 14:24:47 +02:00
bf2512995f labels 2026-07-20 14:02:22 +02:00
8f87a3b477 new connection editing 2026-07-20 13:50:33 +02:00
6a3e9f01d0 Fixed dragging corners 2026-07-20 13:24:37 +02:00
4ab74f64e2 Better drawing and added more icons 2026-07-20 13:18:16 +02:00
ee859c4311 Added new connection styles 2026-07-20 12:45:47 +02:00
8fa450d734 more ui files instead of python generated ui 2026-07-20 12:27:29 +02:00
48a2b4c8d0 Reorganized the application around a clear frontend/backend boundary.
src/bedit/
├── __main__.py
├── core/                   # Pure Python, no PySide
│   ├── model.py
│   ├── port_types.py
│   ├── serializer.py
│   └── libraries.py
└── gui/                    # All Qt-dependent code
    ├── app.py
    ├── main_window.py
    ├── preferences.py
    ├── controllers/
    ├── dialogs/
    ├── graphics/
    ├── models/
    └── generated/          # Designer/resource output only
Notable improvements:
Domain models, serialization, port types, and library parsing are now Qt-free.
Qt signals and undo infrastructure are explicitly isolated under gui/controllers.
Library parsing is separated from the Qt repository and tree models.
All generated Python is contained in gui/generated.
Designer build tasks now write to the generated directory.
The application entry point and package metadata use the new paths.
README now documents the structure and dependency rules.
Removed the old mixed document, library, and workspace packages.
2026-07-20 12:09:00 +02:00
1a47952358 Fixed some small stuff 2026-07-20 11:52:34 +02:00
122 changed files with 21045 additions and 4946 deletions

2
BEdit/.gitignore vendored
View File

@@ -8,3 +8,5 @@ dist/
.idea/
.vscode/*
!.vscode/tasks.json
.ruff_cache
ui/*_ui.py

View File

@@ -2,11 +2,10 @@
"version": "2.0.0",
"tasks": [
{
"label": "Qt: Open Main Window in Designer",
"label": "Qt: Open Designer",
"type": "shell",
"command": "pyside6-designer",
"args": [
"${workspaceFolder}/ui/main_window.ui"
],
"options": {
"cwd": "${workspaceFolder}",
@@ -21,26 +20,6 @@
"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",
@@ -48,7 +27,7 @@
"args": [
"${workspaceFolder}/resources/resources.qrc",
"-o",
"${workspaceFolder}/src/bedit/resources_rc.py"
"${workspaceFolder}/src/bedit/gui/generated/resources_rc.py"
],
"options": {
"cwd": "${workspaceFolder}"
@@ -68,7 +47,7 @@
"--from-imports",
"${workspaceFolder}/ui/main_window.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_main_window.py"
"${workspaceFolder}/src/bedit/gui/generated/ui_main_window.py"
],
"options": {
"cwd": "${workspaceFolder}"
@@ -87,7 +66,7 @@
"--from-imports",
"${workspaceFolder}/ui/settings_dialog.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_settings_dialog.py"
"${workspaceFolder}/src/bedit/gui/generated/ui_settings_dialog.py"
],
"options": {
"cwd": "${workspaceFolder}"
@@ -106,12 +85,44 @@
"--from-imports",
"${workspaceFolder}/ui/component_options_dialog.ui",
"-o",
"${workspaceFolder}/src/bedit/ui_component_options_dialog.py"
"${workspaceFolder}/src/bedit/gui/generated/ui_component_options_dialog.py"
],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": [],
"presentation": {"reveal": "silent", "panel": "shared"}
},
{
"label": "Qt: Compile Port Options UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": ["--from-imports", "${workspaceFolder}/ui/port_options_dialog.ui", "-o", "${workspaceFolder}/src/bedit/gui/generated/ui_port_options_dialog.py"],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Compile Shape Options UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": ["--from-imports", "${workspaceFolder}/ui/shape_options_dialog.ui", "-o", "${workspaceFolder}/src/bedit/gui/generated/ui_shape_options_dialog.py"],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Compile Icon Editor UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": ["--from-imports", "${workspaceFolder}/ui/icon_editor_dialog.ui", "-o", "${workspaceFolder}/src/bedit/gui/generated/ui_icon_editor_dialog.py"],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Compile Simulation Window UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": ["--from-imports", "${workspaceFolder}/ui/simulation_window.ui", "-o", "${workspaceFolder}/src/bedit/gui/generated/ui_simulation_window.py"],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Build Designer Files",
"dependsOrder": "sequence",
@@ -119,7 +130,11 @@
"Qt: Compile Resources to Python",
"Qt: Compile UI to Python",
"Qt: Compile Settings UI to Python",
"Qt: Compile Component Options UI to Python"
"Qt: Compile Component Options UI to Python",
"Qt: Compile Port Options UI to Python",
"Qt: Compile Shape Options UI to Python",
"Qt: Compile Icon Editor UI to Python",
"Qt: Compile Simulation Window UI to Python"
],
"problemMatcher": [],
"group": {

347
BEdit/AGENTS.md Normal file
View File

@@ -0,0 +1,347 @@
# AGENTS.md
This file is the working guide for AI coding agents contributing to BEdit.
Its instructions apply to the entire repository.
## Project purpose
BEdit is a Python/PySide6 graphical editor for hierarchical block and bond-graph
models. A document contains reusable components, typed and oriented ports,
connections, nested graphs, and editable vector icons.
The application is evolving quickly. Prefer small, coherent changes that improve
the architecture without introducing compatibility layers unless compatibility is
explicitly requested.
## Architecture
The main boundary is strict:
```text
src/bedit/
├── __main__.py
├── core/ # Pure Python; must not import PySide6
│ ├── model.py # Document, component, graph, port, icon data
│ ├── port_types.py # Port type definitions and compatibility
│ ├── serializer.py # JSON persistence
│ ├── libraries.py # Library file discovery and parsing
│ └── simulation/ # Qt-free composition and OpenModelica interface code
└── gui/ # All Qt-dependent code
├── app.py # QApplication startup and palette
├── main_window.py # Top-level UI orchestration
├── preferences.py # Explicit disk-backed QSettings factory
├── controllers/ # Document controller and undo commands
├── dialogs/ # Dialog behavior
├── graphics/ # Workspace, icon editor, vector rendering
├── models/ # Qt tree models and repository adapters
└── generated/ # Generated UI/resource Python; never hand-edit
```
Dependency direction:
- `bedit.core` may depend only on the Python standard library.
- `bedit.gui` may depend on `bedit.core` and PySide6.
- `bedit.core` must never import `bedit.gui` or PySide6.
- Data parsing and validation belong in `core`.
- Signals, widgets, painting, Qt models, undo integration, and settings UI belong
in `gui`.
Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
`gui` subpackage rather than the package root.
## Important behavior and design decisions
- Components may contain nested graph or text implementations.
- Ports retain stable IDs. Display names, types, orientation, positions, and icon
anchors may change without changing IDs.
- Parameters belong to `Component` rather than a particular implementation kind,
so graph and text components share stable-ID name/type/value records.
- Components store one ordered `ports` list. Each port's `orientation` field is
authoritative (`input`, `output`, or power-only `indifferent`); connection and
presentation code derives any directional groupings when needed.
- Port types are registered in `core/port_types.py`. Only compatible types may be
connected. Signal ports connect by type; power ports additionally require the
same domain. Editable power domains and causalities live in
`core/power_domains.py`. The `single effort in` and `single flow in`
causalities are available only on power ports that allow multiple connections.
- Power ports may use `indifferent` orientation and can act as either connection
endpoint. For two indifferent ports, click order determines source/arrow
direction; otherwise output/input semantics determine direction.
- Port connector type and signal value type are separate. Signal ports and
parameters carry editable value type, quantity, unit, row/column dimensions,
and description metadata. Editable quantity/unit suggestions live in
`core/physical_types.py`; unlisted values remain valid.
- Port removal or reorientation must be rejected when it would invalidate an
existing connection.
- Connections reference port IDs, never port names.
- Connections store their port type and bond-graph causality explicitly.
`core/bond_graph.py` owns causality inference; the simulation service invokes
it before Modelica composition. The controller copies inferred values into the
live model and refreshes the workspace before compile, export, or run.
Causality values are `none`, `source`, `target`, `warn_source`, and `warn_target`.
- Connection junctions are explicit typed graph objects. Splitting a connection
creates one incoming and one outgoing segment; the junction can source further
branches without overlapping full connection paths. Power bond connections
cannot contain junctions.
- Graph interaction has separate Pointer and Connect modes. Port hints are only
visible in Connect mode. Connecting two blocks opens the compatible port-pair
chooser; explicit port clicks determine its default selection. Connections
use one freely angled polyline format with absolute `properties.waypoints`.
Semantic ports choose compatibility, while each rendered endpoint is the
intersection of the owning hitbox and its center-to-adjacent-route-point ray.
- Connection appearance is configured per port type in
`gui/graphics/connection_styles.py`, including color, width, pen style, and
source/target arrowheads. Arrow styles are `open`, `half`, or `filled`. Do not
scatter those constants through painters.
- Graph annotations are `box`, `line`, or `text` objects in `Graph.annotations`.
They use integer layers below or above graph layer 0. Annotation lines reuse
connection polyline and absolute `properties.waypoints` semantics. Graph
annotations and icon elements share `ShapeOptionsDialog` and `shape_pen` so
their style fields and rendering must remain aligned.
- Icon editing uses a fixed 128×128 coordinate space.
- The visible/selectable component hitbox is calculated from vector elements,
not from the complete 128×128 icon canvas.
- New component icons default to a centered 64×64 shape.
- Vector elements include rectangles, circles, ellipses, lines, triangles, and
text. Shape styling and geometry are stored in document JSON.
- Icon port anchors are stored in `Port.properties["iconPosition"]`.
- Workspace grid size, workspace snapping size, and icon grid size are separate
persisted settings.
- Settings use `gui.preferences.application_settings()` and are stored under the
explicit `BEdit/BEdit` identity. Do not create anonymous `QSettings()` objects.
- Avoid the QSettings group name `general`; Qt treats `General` specially in INI
files. Autosave keys live under `autosave/`.
- User-visible document edits should participate in undo/redo.
- Component clipboard data is shared by the graph, document tree, and library
tree. Pasting into the document node creates roots; pasting into a graph node
creates children at an origin-normalized position. Always clone pasted trees
with fresh IDs, preserve connections between jointly copied graph blocks, and
assign unique sibling names.
- The text-definition editor uses OpenModelica highlighting and completion from
`src/bedit/data/syntax/openmodelica.json`. Keep keywords, types, built-ins, and
named BEdit `$name$` macro completions editable there; arbitrary `$name$`
expressions are highlighted as BEvalues. Highlight colors and bold/italic
styles are persisted under `syntax/<category>/` in application settings.
- Text component sources may contain private Modelica declarations in
`source.declarations`. The text-definition editor exposes declarations above
`source.initialEquations` and `source.equations`, with the same highlighting
and completion in all three fields. The composer emits each in its matching
Modelica section.
- The text-definition view embeds the same `PortEditor` and `ParameterEditor`
implementations used by the standalone options dialogs. Do not reintroduce
separate port/parameter tables in the text editor.
- Application-wide messages use `core.application_log.get_logger()`. The main
window installs the Qt log-panel handler; core code must only use standard
Python logging and must not import the GUI handler.
- File → Reload Simulation Code (`Ctrl+F5`) reloads modules under
`bedit.core.simulation`, replaces the shared application/controller service,
and preserves the previous instance attributes where possible.
- Modelica composition lives in `core/simulation/composer.py`; the simulation
service only owns application state and delegates composition. Ports with
`multipleConnections` are emitted as Modelica arrays. Their size is inferred
per component instance from graph connections and exposed while compiling as
`$portname_N$`; array connection endpoints receive stable one-based indices in
graph connection order.
- Simulation → Export Model composes through `Simulation.compose_source()` without
building the model, then writes the generated source as a `.mo` file.
- OpenModelica integration belongs in `core/simulation/openmodelica.py`. Its
persistent worker and OMC session start lazily on the first queued request.
Never perform OMPython work directly on the Qt GUI thread. Result and error
callbacks run on background threads and must use a Qt signal before touching UI.
One lazy temporary working directory is shared by all requests in the session.
Explicit application shutdown closes OMC and removes that directory plus the
current session's OMPython log and port files; `__del__` is only a fallback.
- Simulation runs start an ephemeral localhost TCP listener before launching the
generated model through OMC's `system()` function. OpenModelica's newline-delimited
`xmltcp` status and message records are parsed in the core and forwarded through
callbacks; the simulation service retains the latest progress for polling.
- The application owns one reusable `SimulationWindow`. Starting a run clears its
progress, log, and future result views. Extend graph presentation through its
Designer-owned `resultsLayout` and the
`clear_result_views()`/`load_result_views()` hooks.
- Simulation-window geometry, dock/toolbar state, and central-results visibility
persist under `simulationWindow/` through `application_settings()`.
- Standalone simulation results are modeled in `core/simulation/results.py`.
Its versioned schema retains model status, messages, metadata, and plottable
traces so the simulation window can open results without an active document.
Human-readable `.json` uses JSON, while the default `.ber` format uses the same
compressed MessagePack approach as `.beb` documents.
- After a successful OpenModelica run, `<model>_res.csv` is parsed on the worker
before temporary-directory cleanup. `SimulationResults.data` stores every CSV
column as a numeric array, including `time`, for later plotting and persistence.
- The simulation window's dockable Signals tree derives hierarchy from dot-separated
result-column names and bracketed array indices (`a[1]` becomes `a → 1`). Leaf
items retain the exact full column name in `UserRole`; plotting code should
consume `SimulationWindow.selected_signal_names()`.
- Simulation graph tabs persist as `SimulationResults.graphs`; every graph has a
stable ID, editable title, and its own `traces` list. Runtime graph widgets belong
in `GraphWorkspacePage.plot_layout`, not in the serialized core model. The
Signals tree checkboxes edit the active graph's traces, and each page embeds a
Matplotlib QtAgg canvas. Each graph persists its own `x_axis` signal (default
`time`), selectable from the Signals tree context menu.
- Embedded Matplotlib canvases provide cursor-centered wheel zoom and direct
left-button drag panning without activating navigation-toolbar modes. Their
custom navigation toolbar restores an explicit data-derived home view.
- Rerunning the same composed model retains graph tabs, ordering, titles, X axes,
and surviving trace settings while replacing numeric data. Missing signals are
pruned from traces/X-axis selection and newly returned columns appear in the tree.
- The optional OpenModelica executable is persisted as
`simulation/openModelicaPath`. The GUI passes it into `Simulation`; core must
not read `QSettings`. An empty path uses OMPython/PATH discovery, while an
explicit `.../bin/omc` path is converted to the OpenModelica home directory.
- Builds enable OpenModelica's `--unitChecking`. Completed simulation results
read per-column units from OMC's generated `<model>_init.xml`; BEdit must not
infer derivative units itself. This metadata is serialized with `.ber`/JSON
results and displayed by the simulation signal tree and plots.
- Result loading restores variables marked `alias` or `negatedAlias` in OMC's
initialization XML. Values come from OMC's result representative or a fixed
parameter/constant start value; BEdit does not infer aliases from graph edges.
- Compile requests call OMC `checkModel` before `buildModel` and log OMC's check
summary verbatim through the application-wide logger.
## Qt Designer and generated files
Editable Designer sources are in `ui/`. Resources are defined in
`resources/resources.qrc`.
Never hand-edit files in `src/bedit/gui/generated/`. Modify the corresponding
`.ui` or `.qrc` source and regenerate instead.
Use the configured VS Code task **Qt: Build Designer Files**, or run the relevant
commands directly:
```bash
pyside6-rcc resources/resources.qrc \
-o src/bedit/gui/generated/resources_rc.py
pyside6-uic --from-imports ui/main_window.ui \
-o src/bedit/gui/generated/ui_main_window.py
pyside6-uic --from-imports ui/settings_dialog.ui \
-o src/bedit/gui/generated/ui_settings_dialog.py
pyside6-uic --from-imports ui/component_options_dialog.ui \
-o src/bedit/gui/generated/ui_component_options_dialog.py
pyside6-uic --from-imports ui/port_options_dialog.ui \
-o src/bedit/gui/generated/ui_port_options_dialog.py
pyside6-uic --from-imports ui/shape_options_dialog.ui \
-o src/bedit/gui/generated/ui_shape_options_dialog.py
pyside6-uic --from-imports ui/icon_editor_dialog.ui \
-o src/bedit/gui/generated/ui_icon_editor_dialog.py
pyside6-uic --from-imports ui/text_definition_editor.ui \
-o src/bedit/gui/generated/ui_text_definition_editor.py
pyside6-uic --from-imports ui/simulation_settings_dialog.ui \
-o src/bedit/gui/generated/ui_simulation_settings_dialog.py
pyside6-uic --from-imports ui/simulation_window.ui \
-o src/bedit/gui/generated/ui_simulation_window.py
pyside6-uic --from-imports ui/graph_parameters_dialog.ui \
-o src/bedit/gui/generated/ui_graph_parameters_dialog.py
pyside6-uic --from-imports ui/parameter_options_dialog.ui \
-o src/bedit/gui/generated/ui_parameter_options_dialog.py
```
When adding a promoted/custom widget in Designer, its header must use the real
Python module path, for example `bedit.gui.graphics.workspace`.
Substantial windows and dialogs must have a Designer `.ui` source. Python classes
bind behavior and data but must not reconstruct or replace those layouts at
runtime. A tiny generic prompt with one field and OK/Cancel may remain code-only.
## Editing conventions
- Preserve stable document IDs and existing connections.
- Validate candidate document changes before pushing an undo command.
- Keep model serialization symmetrical: additions to `to_dict()` require matching
handling in `from_dict()` and cloning where relevant.
- Use descriptive domain names; avoid generic `utils.py` modules.
- Prefer focused classes and helpers over growing `main_window.py` further.
- Shared vector calculations that do not require Qt belong in `core`; Qt painter
code belongs in `gui/graphics`.
- Do not silently swallow malformed document data. Raise a useful `ValueError` in
`core`, then present it through the GUI layer.
- Preserve unrelated user changes. The worktree may already be dirty.
- Do not delete or overwrite library/document JSON unless the requested workflow
explicitly calls for it.
## Document and library files
- Documents use the `bedit-document` JSON format.
- Documents can be stored as human-readable `.bedit.json`/`.json` through
`JsonDocumentSerializer`, or as compressed MessagePack `.beb` through
`BebDocumentSerializer`. UI document I/O dispatches via `DocumentSerializer`.
- `test.bedit.json` is a useful manually created example during development.
- Library documents use the same recursive document model.
- Library parsing belongs in `core/libraries.py`; Qt change notifications belong
in `gui/models/library_repository.py`.
- Package library data, if present, belongs under `src/bedit/data/libraries/`.
## Validation
There is not yet a complete automated test suite. For every change, run at least:
```bash
python3 -m compileall -q src
git diff --check
```
Run imports with the source tree explicitly available:
```bash
PYTHONPATH=src python3 -c "import bedit.core; import bedit.gui.app"
```
Verify the backend remains Qt-free after core changes:
```bash
PYTHONPATH=src python3 - <<'PY'
import sys
import bedit.core
assert not any(name.startswith("PySide6") for name in sys.modules)
PY
```
For model changes, add a focused in-memory round-trip check using
`GraphDocument.to_dict()` and `GraphDocument.from_dict()`. For controller changes,
exercise undo and redo when applicable.
GUI smoke tests may fail in headless environments because the system Qt platform
theme tries to access a display even with an offscreen platform. Do not claim an
interactive GUI test passed unless a display-capable environment was actually
used. Compilation, imports, and pure model/controller checks remain useful.
If Ruff is installed, also run:
```bash
ruff check src
```
## Running the application
Install the package in editable mode or run it from the source tree:
```bash
PYTHONPATH=src python3 -m bedit
```
The installed GUI entry point is `bedit.gui.app:main`.
The first non-flag launch argument is opened as the initial document.
## Completion checklist
Before handing off a change:
1. Confirm the `core`/`gui` dependency boundary is intact.
2. Confirm generated files were not hand-edited.
3. Check serialization and cloning for model changes.
4. Check undo/redo for document mutations.
5. Run compilation and `git diff --check`.
6. Report any GUI behavior that could not be tested interactively.
7. Update README or this file if the architecture or workflow changed.

View File

@@ -36,12 +36,18 @@ After installation, the `bedit` command also launches the application.
.
├── pyproject.toml dependencies, package metadata, and `bedit` command
├── README.md
├── ui/main_window.ui editable Qt Designer source
├── ui/ editable Qt Designer sources
└── 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
├── __main__.py minimal `python -m bedit` entry point
├── core/ Qt-free domain model, port types, files, libraries
── gui/ every PySide-dependent module
├── app.py Qt startup and application palette
├── main_window.py top-level UI orchestration
├── controllers/ document controller and undo commands
├── dialogs/ dialog behavior
├── graphics/ graph workspace and vector icon editor
├── models/ Qt tree models and repository adapters
└── generated/ generated UI/resources; do not hand-edit
```
## Designing the UI further
@@ -61,13 +67,19 @@ 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
pyside6-uic --from-imports ui/main_window.ui \
-o src/bedit/gui/generated/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
Do not hand-edit files in `gui/generated`; change the `.ui` source and regenerate
it. Add behavior and signal connections in `gui/main_window.py`. Widget names from
Designer are available there through `self.ui`, such as `self.ui.graphView`.
Substantial views are all represented in Designer: the main window, settings,
component options, port options, shape options, and icon editor. Python binds
data and behavior to those forms; it does not rebuild their layouts at runtime.
Only tiny generic prompts may be code-only.
In VS Code, the same commands are available through **Terminal → Run Task**:
- **Qt: Open Main Window in Designer** opens the form for visual editing.
@@ -76,16 +88,13 @@ In VS Code, the same commands are available through **Terminal → Run Task**:
- The separate resource and UI compilation tasks remain available when only one
generated file needs rebuilding.
## A sensible next design pass
## Architecture rules
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.
- `bedit.core` must remain importable without PySide6.
- `bedit.gui` may depend on `core`; `core` must never import `gui`.
- Designer output and resource output belong only in `gui/generated`.
- Domain serialization and library parsing stay in `core`; Qt signals, models,
undo integration, painting, and widgets stay in `gui`.
## Graph and library prototype
@@ -101,27 +110,43 @@ BEdit document used as a copy source. The built-in example defines A, B, and C.
- 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.
- Switch the graph header to **Connect** and click two blocks or their temporary
port hints. A chooser lists every compatible output/input pairing in both
directions and preselects the pairing implied by the clicks. Empty-canvas
clicks add freely angled, grid-snapped vertices; right-click cancels.
- Select a routed connection to reveal its draggable nodes. Right-click a line
to add a node, or right-click a node to delete it. Visible wire endpoints are
projected onto each icon's hitbox where the ray from its center toward the
adjacent route point crosses the boundary. Hidden semantic port positions do
not constrain the wire.
- Use **Box**, **Line**, and **Text** to add persistent graph annotations. Lines
use the same freely angled polyline and draggable-node behavior as connections.
Annotation context menus provide the same shape styling as the icon editor and
can move annotations through integer layers below or above graph layer 0.
- Double-click a graph component or select it and use **Down** 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.
- The icon editor uses Pointer and click-drag drawing tools. Its toolbar and
mouse wheel provide zoom in, zoom out, and fit-to-canvas controls. Selected
rectangles expose draggable corner and edge resize handles in both editors.
- 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
- The left navigator presents the current Document and external Libraries as tabs.
- Select one or more blocks and press `Ctrl+R`, or use **Rotate** in the graph header, 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.
workspace. An open graph uses a light gray grid whose visible and snapping
spacing are configured separately.
- Edit → Settings → Libraries accepts JSON or `.beb` document files and folders.
Every component owns its ports, declarative icon, properties, and child graph:
@@ -134,12 +159,12 @@ Every component owns its ports, declarative icon, properties, and child graph:
"name": "My Component",
"position": {"x": 0, "y": 0},
"interface": {
"inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": {
"iconPosition": {"x": 0, "y": 40}
}}],
"outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": {
"iconPosition": {"x": 128, "y": 64}
}}]
"ports": [
{"id": "in", "name": "Input", "type": "signal", "orientation": "input",
"properties": {"iconPosition": {"x": 0, "y": 40}}},
{"id": "out", "name": "Output", "type": "signal", "orientation": "output",
"properties": {"iconPosition": {"x": 128, "y": 64}}}
]
},
"icon": {
"size": {"width": 128, "height": 128},
@@ -169,10 +194,10 @@ never own a graph. Vector icons can contain rectangles, circles, ellipses,
lines, triangles, and text. Each element owns its geometry, fill, stroke, and
line style; ports keep their icon anchor in `properties.iconPosition`. The
port type registry controls which types may connect (currently `signal` only).
Graph and icon grid sizes are configured independently in Settings. 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/`.
The workspace grid size, its finer snapping size, and the icon grid size are
configured independently in Settings. The recursive model and file loading are
under `src/bedit/core/`; the live Qt trees and graphics are isolated under
`src/bedit/gui/models/` and `src/bedit/gui/graphics/`.
## Optional tools

View File

@@ -1,162 +0,0 @@
{
"format": "bedit-document",
"version": 1,
"metadata": {
"name": "Untitled"
},
"roots": [
{
"id": "5ee742db-b25c-4d86-b4ca-cc0e61c4002a",
"name": "New Graph Block 1",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [],
"outputs": []
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Graph",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 1.0,
"y": 1.0,
"width": 126.0,
"height": 126.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 8.0,
"y": 8.0,
"width": 112.0,
"height": 112.0,
"text": "Graph",
"color": "#202020",
"fontSize": 12.0
}
]
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [
{
"id": "89944479-e73e-446a-8d58-ebf35ac4144b",
"name": "New Graph Block 1",
"position": {
"x": 0.0,
"y": -96.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-e5cb81a5",
"name": "Port 1",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 32.0,
"y": 64.0
}
},
"type": "signal"
}
],
"outputs": [
{
"id": "port-eb0034af",
"name": "Port 2",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 96.0,
"y": 64.0
}
},
"type": "signal"
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Graph",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": "Graph",
"color": "#303030",
"fontSize": 12.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"fill": "#ffffff"
}
]
},
"properties": {},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "graph",
"graph": {
"blocks": [],
"connections": []
}
}
}
],
"connections": []
}
}
}
]
}

View File

@@ -9,7 +9,10 @@ description = "A starter Qt desktop application"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"matplotlib>=3.8,<4",
"msgpack>=1.0,<2",
"PySide6>=6.7,<7",
"OMPython>4.0",
]
[project.optional-dependencies]
@@ -19,13 +22,17 @@ dev = [
]
[project.gui-scripts]
bedit = "bedit.app:main"
bedit = "bedit.gui.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
bedit = ["data/libraries/*.json"]
bedit = [
"data/libraries/*.json",
"data/libraries/*.beb",
"data/syntax/*.json",
]
[tool.ruff]
line-length = 100

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,14 +1,36 @@
<RCC>
<qresource prefix="icons">
<file>icons/list-remove.png</file>
<file>icons/list-add.png</file>
<file>icons/view-form-table.png</file>
<file>icons/office-chart-line.png</file>
<file>icons/run-build.png</file>
<file>icons/preferences-system.png</file>
<file>icons/draw-triangle.png</file>
<file>icons/draw-ellipse.png</file>
<file>icons/draw-circle.png</file>
<file>icons/draw-path.png</file>
<file>icons/network-connect.png</file>
<file>icons/arrow-down.png</file>
<file>icons/configure.png</file>
<file>icons/arrow-up.png</file>
<file>icons/document-new.png</file>
<file>icons/document-open.png</file>
<file>icons/document-save-as.png</file>
<file>icons/document-save.png</file>
<file>icons/draw-bezier-curves.png</file>
<file>icons/draw-rectangle.png</file>
<file>icons/draw-text.png</file>
<file>icons/edit-copy.png</file>
<file>icons/edit-cut.png</file>
<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-select.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/media-playback-start.png</file>
<file>icons/transform-rotate.png</file>
<file>icons/zoom-in.png</file>
<file>icons/zoom-original.png</file>
<file>icons/zoom-out.png</file>
</qresource>
</RCC>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,45 +0,0 @@
from PySide6.QtWidgets import QDialog, QMessageBox, QPushButton
from bedit.document.model import Component
from bedit.icon_editor import IconEditorDialog
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.component = component
self.edited_icon = component.icon
self.edited_inputs = component.inputs
self.edited_outputs = component.outputs
self.ui.nameEdit.setText(component.name)
for widget in (
self.ui.shapeLabel, self.ui.shapeCombo, self.ui.iconTextLabel,
self.ui.iconTextEdit, self.ui.fillLabel, self.ui.fillEdit,
self.ui.borderLabel, self.ui.borderEdit,
):
widget.hide()
self.icon_editor_button = QPushButton("Edit Icon…", self)
self.icon_editor_button.setToolTip("Open the vector icon and port-position editor")
self.icon_editor_button.clicked.connect(self.edit_icon)
self.ui.optionsForm.insertRow(1, "Icon:", self.icon_editor_button)
self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library)
def edit_icon(self) -> None:
working = Component.from_dict(self.component.to_dict())
working.icon = self.edited_icon
working.inputs = self.edited_inputs
working.outputs = self.edited_outputs
dialog = IconEditorDialog(working, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.edited_icon = dialog.icon
self.edited_inputs = dialog.inputs
self.edited_outputs = dialog.outputs
def accept(self) -> None:
if not self.ui.nameEdit.text().strip():
QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.")
return
super().accept()

View File

@@ -0,0 +1,5 @@
"""Pure BEdit domain model and persistence; this package has no Qt dependency."""
from bedit.core.model import Component, Connection, Endpoint, Graph, GraphDocument, Icon, Port
__all__ = ["Component", "Connection", "Endpoint", "Graph", "GraphDocument", "Icon", "Port"]

View File

@@ -0,0 +1,9 @@
import logging
LOGGER_NAME = "bedit"
def get_logger(name: str | None = None) -> logging.Logger:
"""Return the shared application logger, optionally scoped to a module."""
return logging.getLogger(LOGGER_NAME if name is None else f"{LOGGER_NAME}.{name}")

View File

@@ -0,0 +1,321 @@
"""Bond-graph analysis hooks.
This module intentionally has no GUI or simulation-engine dependencies. The
causality inference algorithm can grow here without coupling the document model
to OpenModelica or Qt.
"""
from __future__ import annotations
from typing import Any
from bedit.core.application_log import get_logger
log = get_logger(__name__)
def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[str, Any]:
"""Infer power-connection causality in a serialized component tree.
This is currently a traversal stub: it ensures every power connection has a
causality value, while preserving causality already supplied by callers.
Future inference rules should assign ``source``, ``target``,
``warn_source``, or ``warn_target`` here and return the same tree.
The input is mutated and returned so the composer receives the inferred
representation without needing a second document conversion.
"""
if toplevel:
# Reset causalities
reset_causality(component)
implementation = component.get("implementation", {})
graph = implementation.get("graph") if isinstance(implementation, dict) else None
if not isinstance(graph, dict):
return component
id_list = build_id_list(component)
# Fixed causalities
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if port.get('causality', 'indifferent') == 'fixed effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'fixed flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
# Preferred causalities
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if is_port_fully_assigned(block, port, graph):
continue
if port.get('causality', 'indifferent') == 'preferred effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'preferred flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
# Soft choices may need several passes when a junction has multiple
# unresolved bonds. Stop as soon as a pass makes no further progress.
while True:
unresolved_before = sum(
con.get('type') == 'power' and con.get('causality', 'none') == 'none'
for con in graph.get('connections', [])
)
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if is_port_fully_assigned(block, port, graph):
continue
if port.get('causality', 'indifferent') == 'likes effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'likes flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single effort in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single flow in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'indifferent':
# Force an arbitrary assignment on the first unassigned bond connected to this port
propagate_from_port(block, port, 'effort out', graph, id_list)
unresolved_after = sum(
con.get('type') == 'power' and con.get('causality', 'none') == 'none'
for con in graph.get('connections', [])
)
if unresolved_after == 0 or unresolved_after >= unresolved_before:
break
# Last check
for con in graph.get('connections', []):
if con.get('type') == 'power' and con.get('causality', 'none') == 'none':
raise ValueError("System under-constrained: unresolved causal loops or disconnected elements remain")
for block in graph.get("blocks", []):
if isinstance(block, dict):
infer_causality(block, False)
return component
def reset_causality(component: dict[str, Any]) -> None:
implementation = component.get("implementation", {})
graph = implementation.get("graph") if isinstance(implementation, dict) else None
if not isinstance(graph, dict):
return
for connection in graph.get("connections", []):
if isinstance(connection, dict) and connection.get("type") == "power":
connection["causality"] = "none"
for block in graph.get("blocks", []):
if isinstance(block, dict):
reset_causality(block)
def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
"""Index all addressable objects in a component tree by their stable ID."""
id_list: dict[str, Any] = {}
id_kinds: dict[str, str] = {}
def add(item: dict[str, Any], description: str) -> None:
item_id = item.get("id")
if not item_id:
raise ValueError(f"{description} has no ID")
# Port IDs identify a port on a component definition and may therefore
# recur in cloned component instances. Component and junction IDs are
# document objects and must remain globally unique.
if item_id in id_list and not (
description == "port" and id_kinds[item_id] == "port"
):
raise ValueError(f"Duplicate simulation object ID: {item_id}")
id_list[item_id] = item
id_kinds[item_id] = description
def visit(component: dict[str, Any]) -> None:
add(component, "component")
interface = component.get("interface", {})
for port in interface.get("ports", []):
add(port, "port")
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return
nested_graph = implementation.get("graph", {})
for junction in nested_graph.get("junctions", []):
add(junction, "junction")
for block in nested_graph.get("blocks", []):
visit(block)
visit(graph)
return id_list
def is_port_fully_assigned(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> bool:
block_id = block.get('id')
port_id = port.get('id')
for connection in graph.get('connections', []):
if connection.get('type') != 'power':
continue
source = connection.get('source', {})
target = connection.get('target', {})
if ((source.get('block') == block_id and source.get('port') == port_id)
or (target.get('block') == block_id and target.get('port') == port_id)):
if connection.get('causality', 'none') == 'none':
return False
return True
def propagate_from_port(block: dict[str, Any], port: dict[str, Any], desired_causality: str, graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# find all connection on this specific port
attached_connections = []
for con in graph.get('connections', []):
if con.get('type') == 'power':
if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'):
attached_connections.append(con)
elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'):
attached_connections.append(con)
if not attached_connections:
# unconnected port
return
# find first unassigned connection on this port
target_connection = None
for con in attached_connections:
if con.get('causality', None) == 'none':
target_connection = con
break
if target_connection is None:
# Nothing left to resolve
return
# Update
if target_connection.get('source').get('block') == block.get('id'):
target_connection['causality'] = 'target' if (desired_causality == 'effort out') else 'source'
else:
target_connection['causality'] = 'source' if (desired_causality == 'effort out') else 'target'
propagate_to_neighbor_from_connection(block, target_connection, graph, id_list)
def evaluate_junction_constraints(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# find all connection on this specific port
attached_connections = []
for con in graph.get('connections', []):
if con.get('type') == 'power':
if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'):
attached_connections.append(con)
elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'):
attached_connections.append(con)
if not attached_connections:
# unconnected port
return
efforts_in = 0
efforts_out = 0
unnassigned_conns = []
# Count current states relative to the junction port
for con in attached_connections:
if con.get('causality', 'none') == 'none':
unnassigned_conns.append(con)
continue
is_source = (con.get('source').get('block') == block.get('id'))
if (is_source and con.get('causality', 'none')=='source') or (not is_source and con.get('causality', 'none')=='target'):
efforts_in += 1
else:
efforts_out += 1
# Single effort in
if port.get('causality', 'indifferent') == 'single effort in':
# ERROR CHECK: Multiple sources/blocks trying to claim effort control on a 0-junction
if efforts_in>1:
raise ValueError(f"Critical causality conflict: Multiple blocks are dictating effort to 0 junction: {block.get('name')}")
# 1 effort is coming in so all other ports must be effort out
if efforts_in == 1 and len(unnassigned_conns) > 0:
for con in unnassigned_conns:
desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# No effort is coming in yet and one left so must be effort in
if efforts_in == 0 and len(unnassigned_conns) == 1:
con = unnassigned_conns[0]
desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# Single flow in
elif port.get('causality', 'indifferent') == 'single flow in':
# ERROR CHECK: Multiple sources/blocks trying to claim flow control on a 1-junction
if efforts_out>1:
raise ValueError(f"Critical causality conflict: Multiple blocks are dictating flow to 1 junction: {block.get('name')}")
# 1 flow is coming in so all other ports must be flow out
if efforts_out == 1 and len(unnassigned_conns) > 0:
for con in unnassigned_conns:
desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# No flow is coming in yet and one left so must be flow in
if efforts_out == 0 and len(unnassigned_conns) == 1:
con = unnassigned_conns[0]
desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
def propagate_to_neighbor_from_connection(block: dict[str, Any], connection: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# Get the neighbor block and port on the other side of the bond
neighbor_id = connection.get('target').get('block') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('block')
neighbor_port_id = connection.get('target').get('port') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('port')
neighbor_block = id_list.get(neighbor_id, None)
if neighbor_block is None:
raise ValueError(f"No other side of the bond found on connection {connection.get('id')}")
neighbor_ports = neighbor_block.get('interface').get('ports', [])
neighbor_port = None
for p in neighbor_ports:
if p.get('id') == neighbor_port_id:
neighbor_port = p
break
if neighbor_port is None:
raise ValueError(f"No other side of the bond found on connection {connection.get('id')}")
# Trigger evaluation on the neighbor
if neighbor_port.get('causality', 'indifferent') in ['single effort in', 'single flow in']:
evaluate_junction_constraints(neighbor_block, neighbor_port, graph, id_list)
else:
verify_component_compatibility(neighbor_block, neighbor_port, graph)
def verify_component_compatibility(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> None:
# Find the connection we are evaluating
conn = None
for c in graph.get('connections', []):
if (c.get('source').get('block') == block.get('id') and c.get('source').get('port') == port.get('id')) or (c.get('target').get('block') == block.get('id') and c.get('target').get('port') == port.get('id')):
if c.get('causality', 'none') != 'none':
conn = c
break
if conn is None:
return
# Figure out what causality was pushed onto this port from the outside world
state = conn.get('causality', 'none')
is_source = conn.get('source', {}).get('block') == block.get('id')
effort_out = (is_source and state == 'target') or (not is_source and state == 'source')
port_causality = port.get('causality', 'indifferent')
if port_causality == 'fixed effort out' and not effort_out:
raise ValueError(f"Critical source conflict: Fixed effort source '{block.get('name')}' forced into an input state.")
if port_causality == 'fixed flow out' and effort_out:
raise ValueError(f"Critical source conflict: Fixed flow source '{block.get('name')}' forced into an input state.")
if port_causality == 'preferred effort out' and not effort_out:
conn['causality'] = f'warn_{state}'
# log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).")
if port_causality == 'preferred flow out' and effort_out:
conn['causality'] = f'warn_{state}'
# log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).")

View File

@@ -0,0 +1,32 @@
from dataclasses import dataclass
from pathlib import Path
from bedit.core.model import GraphDocument
from bedit.core.serializer import DocumentSerializer
@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" / "default.beb"
def default_library_paths() -> list[str]:
return [str(bundled_library_path())]
def load_library_file(path: Path) -> LibraryDocument:
document = DocumentSerializer.load(path)
name = str(document.metadata.get("name") or path.stem)
return LibraryDocument(name, document, str(path))
def library_candidates(path: Path) -> list[Path]:
if not path.is_dir():
return [path]
return sorted((*path.glob("*.json"), *path.glob("*.beb")))

View File

@@ -0,0 +1,769 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from bedit.core.port_types import PortTypeRegistry
from bedit.core.power_domains import (
MULTI_CONNECTION_POWER_CAUSALITIES,
POWER_CAUSALITIES,
POWER_DOMAINS,
)
def _dimensions(data: Any, subject: str) -> tuple[int, int]:
if not isinstance(data, dict):
raise ValueError(f"{subject} dimensions must be an object")
try:
rows = int(data.get("rows", 1))
columns = int(data.get("columns", 1))
except (TypeError, ValueError) as error:
raise ValueError(f"{subject} dimensions must be whole numbers") from error
if rows < 1 or columns < 1:
raise ValueError(f"{subject} dimensions must be at least 1")
return rows, columns
@dataclass
class Port:
id: str
name: str
x: float = 0.0
y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
allows_multiple_connections: bool = False
value_type: str = "real"
quantity: str = ""
unit: str = ""
rows: int = 1
columns: int = 1
description: str = ""
orientation: str = "input"
domain: str = "power"
causality: str = "indifferent"
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"properties": self.properties,
"type": self.type,
"multipleConnections": self.allows_multiple_connections,
"valueType": self.value_type,
"quantity": self.quantity,
"unit": self.unit,
"dimensions": {"rows": self.rows, "columns": self.columns},
"description": self.description,
"orientation": self.orientation,
"domain": self.domain,
"causality": self.causality,
}
@classmethod
def from_dict(cls, data: dict[str, Any], default_orientation: str = "input") -> "Port":
position = data.get("position", {})
rows, columns = _dimensions(data.get("dimensions", {}), "Port")
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", {})),
type=str(data.get("type", "signal")),
allows_multiple_connections=bool(data.get("multipleConnections", False)),
value_type=str(data.get("valueType", "real")),
quantity=str(data.get("quantity", "")),
unit=str(data.get("unit", "")),
rows=rows,
columns=columns,
description=str(data.get("description", "")),
orientation=str(data.get("orientation", default_orientation)),
domain=str(data.get("domain", "power")),
causality=str(data.get("causality", "indifferent")),
)
@dataclass
class Icon:
shape: str = "rectangle"
fill: str = "#f4f4f4"
border: str = "#303030"
text: str = ""
width: float = 128.0
height: float = 128.0
elements: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.elements:
self.elements = [
{
"type": "ellipse" if self.shape == "ellipse" else "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": self.fill,
"stroke": self.border,
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0,
}
]
if self.text:
self.elements.append(
{
"type": "text",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"text": self.text,
"color": "#202020",
"fontSize": 12.0,
}
)
def to_dict(self) -> dict[str, Any]:
return {
"shape": self.shape,
"fill": self.fill,
"border": self.border,
"text": self.text,
"size": {"width": self.width, "height": self.height},
"elements": deepcopy(self.elements),
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {}
icon = cls(
shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")),
text=str(data.get("text", "")),
width=128.0,
height=128.0,
elements=deepcopy(data.get("elements", [])),
)
return icon
@dataclass(frozen=True)
class Endpoint:
block: str | None = None
port: str | None = None
interface: str | None = None
junction: str | None = None
def to_dict(self) -> dict[str, str]:
if self.interface is not None:
return {"interface": self.interface}
if self.junction is not None:
return {"junction": self.junction}
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"]))
if "junction" in data:
return cls(junction=str(data["junction"]))
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)
type: str = "signal"
causality: str = "none"
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,
"type": self.type,
"causality": self.causality,
}
@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", {})),
type=str(data.get("type", "signal")),
causality=str(data.get("causality", "none")),
)
@dataclass
class Parameter:
id: str
name: str
type: str = "real"
value: str = "0"
quantity: str = ""
unit: str = ""
rows: int = 1
columns: int = 1
description: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"type": self.type,
"value": self.value,
"quantity": self.quantity,
"unit": self.unit,
"dimensions": {"rows": self.rows, "columns": self.columns},
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Parameter":
if not isinstance(data, dict):
raise ValueError("Each component parameter must be an object")
if "id" not in data:
raise ValueError("Each component parameter must have an ID")
rows, columns = _dimensions(data.get("dimensions", {}), "Parameter")
return cls(
id=str(data["id"]),
name=str(data.get("name", "")),
type=str(data.get("type", "real")),
value=str(data.get("value", "0")),
quantity=str(data.get("quantity", "")),
unit=str(data.get("unit", "")),
rows=rows,
columns=columns,
description=str(data.get("description", "")),
)
@dataclass
class Junction:
id: str
x: float
y: float
type: str = "signal"
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"position": {"x": self.x, "y": self.y},
"type": self.type,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Junction":
position = data.get("position", {})
return cls(
id=str(data["id"]),
x=float(position.get("x", 0)),
y=float(position.get("y", 0)),
type=str(data.get("type", "signal")),
)
@dataclass
class Annotation:
id: str
kind: str
x: float = 0.0
y: float = 0.0
width: float = 0.0
height: float = 0.0
text: str = ""
layer: int = -1
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"kind": self.kind,
"position": {"x": self.x, "y": self.y},
"size": {"width": self.width, "height": self.height},
"text": self.text,
"layer": self.layer,
"properties": deepcopy(self.properties),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Annotation":
position = data.get("position", {})
size = data.get("size", {})
return cls(
id=str(data["id"]),
kind=str(data["kind"]),
x=float(position.get("x", 0)),
y=float(position.get("y", 0)),
width=float(size.get("width", 0)),
height=float(size.get("height", 0)),
text=str(data.get("text", "")),
layer=int(data.get("layer", -1)),
properties=deepcopy(data.get("properties", {})),
)
@dataclass
class Graph:
blocks: dict[str, Component] = field(default_factory=dict)
connections: dict[str, Connection] = field(default_factory=dict)
annotations: dict[str, Annotation] = field(default_factory=dict)
junctions: dict[str, Junction] = field(default_factory=dict)
simulation_settings: dict[str, Any] = 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()],
"annotations": [item.to_dict() for item in self.annotations.values()],
"junctions": [junction.to_dict() for junction in self.junctions.values()],
"simulation": deepcopy(self.simulation_settings),
}
@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", [])]
annotations = [Annotation.from_dict(item) for item in data.get("annotations", [])]
junctions = [Junction.from_dict(item) for item in data.get("junctions", [])]
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")
if len({item.id for item in annotations}) != len(annotations):
raise ValueError("A graph contains duplicate annotation IDs")
if len({item.id for item in junctions}) != len(junctions):
raise ValueError("A graph contains duplicate junction IDs")
if any(item.kind not in {"box", "line", "text"} for item in annotations):
raise ValueError("A graph contains an unknown annotation kind")
return cls(
blocks={block.id: block for block in blocks},
connections={connection.id: connection for connection in connections},
annotations={item.id: item for item in annotations},
junctions={junction.id: junction for junction in junctions},
simulation_settings=deepcopy(data.get("simulation", {})),
)
@dataclass
class Component:
id: str
name: str
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
ports: list[Port] = field(default_factory=list)
parameters: list[Parameter] = 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": {"ports": [port.to_dict() for port in self.ports]},
"parameters": [parameter.to_dict() for parameter in self.parameters],
"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}")
source: dict[str, Any] = {}
parameters = data.get("parameters", [])
if kind == "text":
raw_source = implementation.get("source", {})
equations = raw_source.get("equations", "")
declarations = raw_source.get("declarations", "")
initial_equations = raw_source.get("initialEquations", "")
parameters = data.get("parameters", raw_source.get("parameters", []))
if not isinstance(equations, str):
raise ValueError("Text component equations must be a string")
if not isinstance(declarations, str):
raise ValueError("Text component declarations must be a string")
if not isinstance(initial_equations, str):
raise ValueError("Text component initial equations must be a string")
source = {
"equations": equations,
"declarations": declarations,
"initialEquations": initial_equations,
}
if not isinstance(parameters, list):
raise ValueError("Component parameters must be a list")
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)),
ports=[Port.from_dict(item) for item in interface.get("ports", [])],
parameters=[Parameter.from_dict(item) for item in parameters],
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=source,
)
@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": "Current Document"})
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()
root_names = [component.name for component in self.roots.values()]
if len(set(root_names)) != len(root_names):
raise ValueError("Root component names must be unique")
for component in self.all_components():
if component.id in seen:
raise ValueError(f"Duplicate component ID: {component.id}")
seen.add(component.id)
parameter_ids = [parameter.id for parameter in component.parameters]
parameter_names = [parameter.name for parameter in component.parameters]
if len(set(parameter_ids)) != len(parameter_ids):
raise ValueError(f"Component {component.name!r} has duplicate parameter IDs")
if len(set(parameter_names)) != len(parameter_names):
raise ValueError(f"Component {component.name!r} has duplicate parameter names")
if any(not name.strip() for name in parameter_names):
raise ValueError(f"Component {component.name!r} has an unnamed parameter")
if any(not parameter.type.strip() for parameter in component.parameters):
raise ValueError(f"Component {component.name!r} has an untyped parameter")
if any(
parameter.rows < 1 or parameter.columns < 1
for parameter in component.parameters
):
raise ValueError(f"Component {component.name!r} has invalid parameter dimensions")
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:
child_names = [component.name for component in owner.graph.blocks.values()]
if len(set(child_names)) != len(child_names):
raise ValueError(
f"Component names inside {owner.name!r} must be unique"
)
port_ids = {port.id for port in owner.ports}
if len(port_ids) != len(owner.ports):
raise ValueError(f"Component {owner.name} contains duplicate port IDs")
for port in owner.ports:
PortTypeRegistry.get(port.type)
if port.orientation not in {"input", "output", "indifferent"}:
raise ValueError(f"Port {port.name!r} has an invalid orientation")
if port.orientation == "indifferent" and port.type != "power":
raise ValueError("Only power ports may have indifferent orientation")
if port.type == "power":
if port.domain not in {domain.id for domain in POWER_DOMAINS}:
raise ValueError(f"Power port {port.name!r} has an unknown domain")
if port.causality not in POWER_CAUSALITIES:
raise ValueError(f"Power port {port.name!r} has invalid causality")
if (
port.causality in MULTI_CONNECTION_POWER_CAUSALITIES
and not port.allows_multiple_connections
):
raise ValueError(
f"Power port {port.name!r} requires multiple connections "
f"for causality {port.causality!r}"
)
if not port.value_type.strip():
raise ValueError(f"Port {port.name!r} has no value type")
if port.rows < 1 or port.columns < 1:
raise ValueError(f"Port {port.name!r} has invalid dimensions")
for junction in owner.graph.junctions.values():
PortTypeRegistry.get(junction.type)
if junction.type == "power":
raise ValueError("Power bond connections cannot contain junctions")
endpoint_counts: dict[tuple[str, str, str], int] = {}
for connection in owner.graph.connections.values():
if connection.causality not in {
"none",
"source",
"target",
"warn_source",
"warn_target",
}:
raise ValueError(f"Connection {connection.id} has invalid causality")
if connection.source.junction is not None:
junction = owner.graph.junctions.get(connection.source.junction)
if junction is None:
raise ValueError(
f"Connection {connection.id} uses an unknown source junction"
)
source_port = Port(junction.id, "Junction", type=junction.type)
elif connection.source.interface is not None:
source_ports = [
port
for port in owner.ports
if port.orientation in {"input", "indifferent"}
]
if connection.source.interface not in {port.id for port in source_ports}:
raise ValueError(f"Connection {connection.id} uses an unknown interface input")
source_port = next(
port for port in source_ports if port.id == connection.source.interface
)
else:
source = owner.graph.blocks.get(connection.source.block or "")
source_ports = (
[]
if source is None
else [
port
for port in source.ports
if port.orientation in {"output", "indifferent"}
]
)
if source is None or connection.source.port not in {p.id for p in source_ports}:
raise ValueError(f"Connection {connection.id} uses an unknown block output")
source_port = next(p for p in source_ports if p.id == connection.source.port)
if connection.target.junction is not None:
junction = owner.graph.junctions.get(connection.target.junction)
if junction is None:
raise ValueError(
f"Connection {connection.id} uses an unknown target junction"
)
target_port = Port(
junction.id,
"Junction",
type=junction.type,
allows_multiple_connections=False,
)
elif connection.target.interface is not None:
target_ports = [
port
for port in owner.ports
if port.orientation in {"output", "indifferent"}
]
if connection.target.interface not in {port.id for port in target_ports}:
raise ValueError(f"Connection {connection.id} uses an unknown interface output")
target_port = next(
port for port in target_ports if port.id == connection.target.interface
)
else:
target = owner.graph.blocks.get(connection.target.block or "")
target_ports = (
[]
if target is None
else [
port
for port in target.ports
if port.orientation in {"input", "indifferent"}
]
)
if target is None or connection.target.port not in {
port.id for port in target_ports
}:
raise ValueError(f"Connection {connection.id} uses an unknown block input")
target_port = next(
port for port in target_ports if port.id == connection.target.port
)
if not PortTypeRegistry.compatible(
source_port.type,
target_port.type,
source_port.domain,
target_port.domain,
):
raise ValueError(f"Connection {connection.id} joins incompatible port types")
# The endpoint ports remain authoritative. This also upgrades older
# documents whose connections predate the explicit type field.
connection.type = source_port.type
source_key = (
"source-junction"
if connection.source.junction is not None
else "source-interface"
if connection.source.interface is not None
else "source-block",
connection.source.block or "",
connection.source.junction
or connection.source.interface
or connection.source.port
or "",
)
target_key = (
"target-junction"
if connection.target.junction is not None
else "target-interface"
if connection.target.interface is not None
else "target-block",
connection.target.block or "",
connection.target.junction
or connection.target.interface
or connection.target.port
or "",
)
endpoint_counts[source_key] = endpoint_counts.get(source_key, 0) + 1
endpoint_counts[target_key] = endpoint_counts.get(target_key, 0) + 1
if (
endpoint_counts[target_key] > 1
and not target_port.allows_multiple_connections
):
raise ValueError(
f"Input {target_port.name!r} has multiple incoming connections "
"but does not allow them"
)
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}
junction_ids = {
junction.id: str(uuid4()) for junction in current.graph.junctions.values()
}
def remap(endpoint: Endpoint) -> Endpoint:
if endpoint.interface is not None:
return endpoint
if endpoint.junction is not None:
return Endpoint(junction=junction_ids[endpoint.junction])
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),
connection.type,
connection.causality,
)
for connection in current.graph.connections.values()
for new_id in [str(uuid4())]
},
annotations={
new_id: Annotation(
new_id,
annotation.kind,
annotation.x,
annotation.y,
annotation.width,
annotation.height,
annotation.text,
annotation.layer,
deepcopy(annotation.properties),
)
for annotation in current.graph.annotations.values()
for new_id in [str(uuid4())]
},
junctions={
junction_ids[junction.id]: Junction(
junction_ids[junction.id], junction.x, junction.y, junction.type
)
for junction in current.graph.junctions.values()
},
simulation_settings=deepcopy(current.graph.simulation_settings),
)
return Component(
id=str(uuid4()),
name=current.name,
x=current.x,
y=current.y,
ports=[Port.from_dict(port.to_dict()) for port in current.ports],
parameters=deepcopy(current.parameters),
icon=Icon.from_dict(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,47 @@
"""Editable suggestions for signal and parameter physical metadata.
These values populate editable combo boxes; documents may use values not listed
here. They are suggestions rather than a validation registry.
"""
VALUE_TYPES = ("real", "integer", "boolean", "string")
QUANTITIES = (
"",
"Angle",
"AngularVelocity",
"Current",
"Energy",
"Force",
"Frequency",
"Length",
"Mass",
"Power",
"Pressure",
"Temperature",
"Time",
"Torque",
"Velocity",
"Voltage",
)
UNITS = (
"",
"1",
"A",
"Hz",
"J",
"K",
"N",
"N.m",
"Pa",
"V",
"W",
"deg",
"kg",
"m",
"m/s",
"rad",
"rad/s",
"s",
)

View File

@@ -14,6 +14,7 @@ class PortType:
class PortTypeRegistry:
_types = {
"signal": PortType("signal", "Signal", "A scalar signal connection"),
"power": PortType("power", "Power", "A two-variable power connection"),
}
@classmethod
@@ -28,5 +29,13 @@ class PortTypeRegistry:
raise ValueError(f"Unknown port type: {type_id}") from error
@classmethod
def compatible(cls, first: str, second: str) -> bool:
return cls.get(first).accepts(cls.get(second))
def compatible(
cls,
first: str,
second: str,
first_domain: str = "",
second_domain: str = "",
) -> bool:
if not cls.get(first).accepts(cls.get(second)):
return False
return first != "power" or first_domain == second_domain

View File

@@ -0,0 +1,49 @@
"""Editable power-port domain definitions used by the port editor."""
from dataclasses import dataclass
@dataclass(frozen=True)
class PowerDomain:
id: str
display_name: str
effort: str
flow: str
POWER_DOMAINS: tuple[PowerDomain, ...] = (
PowerDomain("power", "Power", "p.e", "p.f"),
)
def power_domain(domain_id: str) -> PowerDomain:
return next((domain for domain in POWER_DOMAINS if domain.id == domain_id), POWER_DOMAINS[0])
STANDARD_POWER_CAUSALITIES = (
"fixed flow out",
"fixed effort out",
"preferred flow out",
"preferred effort out",
"likes flow out",
"likes effort out",
"indifferent",
)
MULTI_CONNECTION_POWER_CAUSALITIES = (
"single effort in",
"single flow in",
)
POWER_CAUSALITIES = (
*STANDARD_POWER_CAUSALITIES,
*MULTI_CONNECTION_POWER_CAUSALITIES,
)
def power_causalities(allows_multiple_connections: bool = False) -> tuple[str, ...]:
"""Return the causalities available for one power port configuration."""
if allows_multiple_connections:
return POWER_CAUSALITIES
return STANDARD_POWER_CAUSALITIES

View File

@@ -0,0 +1,76 @@
import json
import zlib
from pathlib import Path
import msgpack
from bedit.core.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)
class BebDocumentSerializer:
"""Compressed MessagePack serializer for the binary .beb format."""
MAGIC = b"BEB\x00"
VERSION = 1
@classmethod
def load(cls, path: Path) -> GraphDocument:
payload = path.read_bytes()
header = cls.MAGIC + bytes([cls.VERSION])
if not payload.startswith(header):
raise ValueError("This is not a supported BEdit binary document")
try:
data = msgpack.unpackb(zlib.decompress(payload[len(header) :]), raw=False)
except (ValueError, zlib.error, msgpack.exceptions.MsgpackException) as error:
raise ValueError("The BEdit binary document is damaged") from error
if not isinstance(data, dict):
raise ValueError("The BEdit binary document has an invalid root value")
return GraphDocument.from_dict(data)
@classmethod
def save(cls, document: GraphDocument, path: Path) -> None:
packed = msgpack.packb(document.to_dict(), use_bin_type=True)
payload = cls.MAGIC + bytes([cls.VERSION]) + zlib.compress(packed, level=9)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_bytes(payload)
temporary_path.replace(path)
class DocumentSerializer:
"""Select the document serializer from its file extension."""
@staticmethod
def load(path: Path) -> GraphDocument:
serializer = (
BebDocumentSerializer
if path.suffix.lower() == ".beb"
else JsonDocumentSerializer
)
return serializer.load(path)
@staticmethod
def save(document: GraphDocument, path: Path) -> None:
serializer = (
BebDocumentSerializer
if path.suffix.lower() == ".beb"
else JsonDocumentSerializer
)
serializer.save(document, path)

View File

@@ -0,0 +1,24 @@
from bedit.core.simulation.service import Simulation
from bedit.core.simulation.openmodelica import ModelBuildResult, OpenModelicaInterface
from bedit.core.simulation.results import (
BerSimulationResultsSerializer,
JsonSimulationResultsSerializer,
SimulationExecutionResult,
SimulationGraph,
SimulationResults,
SimulationResultsSerializer,
SimulationTrace,
)
__all__ = [
"OpenModelicaInterface",
"ModelBuildResult",
"Simulation",
"SimulationResults",
"SimulationExecutionResult",
"SimulationGraph",
"SimulationResultsSerializer",
"SimulationTrace",
"BerSimulationResultsSerializer",
"JsonSimulationResultsSerializer",
]

View File

@@ -0,0 +1,452 @@
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
_MODELICA_TYPES = {
"real": "Real",
"integer": "Integer",
"boolean": "Boolean",
"string": "String",
}
_BEVALUE_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)\$")
@dataclass(frozen=True)
class CompositionResult:
"""The intermediate data and generated source produced by composition."""
graph: dict[str, Any]
objects_by_id: dict[str, Any]
modelica: str
model_name: str
def compose_graph(graph: dict[str, Any]) -> CompositionResult:
"""Clean, index, and compose a serialized component tree."""
cleaned_graph = cleanup_graph(deepcopy(graph))
objects_by_id = build_id_list(cleaned_graph)
return CompositionResult(
graph=cleaned_graph,
objects_by_id=objects_by_id,
modelica=emit_model(cleaned_graph, objects_by_id),
model_name=model_name_for(cleaned_graph),
)
def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
"""Index all addressable objects in a component tree by their stable ID."""
id_list: dict[str, Any] = {}
id_kinds: dict[str, str] = {}
def add(item: dict[str, Any], description: str) -> None:
item_id = item.get("id")
if not item_id:
raise ValueError(f"{description} has no ID")
# Port IDs identify a port on a component definition and may therefore
# recur in cloned component instances. Component and junction IDs are
# document objects and must remain globally unique.
if item_id in id_list and not (
description == "port" and id_kinds[item_id] == "port"
):
raise ValueError(f"Duplicate simulation object ID: {item_id}")
id_list[item_id] = item
id_kinds[item_id] = description
def visit(component: dict[str, Any]) -> None:
add(component, "component")
interface = component.get("interface", {})
for port in interface.get("ports", []):
add(port, "port")
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return
nested_graph = implementation.get("graph", {})
for junction in nested_graph.get("junctions", []):
add(junction, "junction")
for block in nested_graph.get("blocks", []):
visit(block)
visit(graph)
return id_list
def emit_model(
graph: dict[str, Any],
id_list: dict[str, Any],
indent: int = 0,
connection_counts: dict[str, int] | None = None,
connection_signs: dict[str, list[float]] | None = None,
) -> str:
"""Emit a component and its nested definitions as Modelica source."""
del id_list # Kept in the public API for composer extensions and inspection.
indentation = "\t" * indent
body_indent = "\t" * (indent + 1)
model_name = model_name_for(graph)
lines = [f"{indentation}model {model_name}"]
implementation = graph.get("implementation", {})
implementation_kind = implementation.get("kind")
nested_graph = implementation.get("graph", {})
port_counts = connection_counts or _interface_connection_counts(graph)
port_signs = connection_signs or _interface_connection_signs(graph)
macros = _port_connection_macros(graph, port_counts, port_signs)
if indent == 0 and _contains_power_port(graph):
lines.extend(
(
f"{body_indent}connector BondPort \"Bond graph power port\"",
f"{body_indent}\tReal e \"Effort variable\";",
f"{body_indent}\tflow Real f \"Flow variable\";",
f"{body_indent}end BondPort;",
)
)
if implementation_kind == "graph":
for block in nested_graph.get("blocks", []):
block_counts, block_signs = _block_connection_data(
nested_graph, block["id"]
)
lines.extend(
emit_model(
block,
{},
indent + 1,
block_counts,
block_signs,
)
.rstrip()
.splitlines()
)
interface = graph.get("interface", {})
for port in interface.get("ports", []):
direction = "output" if port.get("orientation") == "output" else "input"
lines.append(_port_declaration(port, direction, indent + 1, macros))
for parameter in graph.get("parameters", []):
parameter_type = modelica_type(parameter.get("type", "real"))
parameter_name = identifier(parameter["name"])
value = expand_bevalues(str(parameter.get("value", "0")), macros)
dimensions = _fixed_dimensions(parameter)
attributes = _quantity_unit_attributes(parameter)
lines.append(
f"{body_indent}parameter {parameter_type} {parameter_name}"
f"{dimensions}{attributes} = {value};"
)
if implementation_kind == "text":
declarations = str(implementation.get("source", {}).get("declarations", ""))
declarations = expand_bevalues(declarations, macros)
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in declarations.splitlines()
)
if implementation_kind == "graph":
for block in nested_graph.get("blocks", []):
block_type = model_name_for(block)
block_name = identifier(block["name"])
lines.append(f"{body_indent}{block_type} {block_name};")
for junction in nested_graph.get("junctions", []):
junction_type = modelica_type(junction.get("type", "signal"))
lines.append(
f"{body_indent}{junction_type} {_junction_name(junction['id'])};"
)
if implementation_kind == "graph":
lines.append(f"{indentation}equation")
blocks = {block["id"]: block for block in nested_graph.get("blocks", [])}
junctions = {
junction["id"]: junction for junction in nested_graph.get("junctions", [])
}
endpoint_indices: dict[tuple[str, str, str], int] = {}
for connection in nested_graph.get("connections", []):
source = _endpoint_expression(
connection["source"], graph, blocks, junctions, endpoint_indices
)
target = _endpoint_expression(
connection["target"], graph, blocks, junctions, endpoint_indices
)
if connection.get("type") == "power":
lines.append(f"{body_indent}connect({source}, {target});")
else:
lines.append(f"{body_indent}{target} = {source};")
else:
initial_equations = str(
implementation.get("source", {}).get("initialEquations", "")
)
initial_equations = expand_bevalues(initial_equations, macros)
equations = str(implementation.get("source", {}).get("equations", ""))
equations = expand_bevalues(equations, macros)
if initial_equations.strip():
lines.append(f"{indentation}initial equation")
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in initial_equations.splitlines()
)
lines.append(f"{indentation}equation")
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in equations.splitlines()
)
lines.append(f"{indentation}end {model_name};")
return "\n".join(lines) + "\n"
def cleanup_graph(graph: dict[str, Any]) -> dict[str, Any]:
"""Remove annotations and UI-only data from a serialized component tree."""
def remove_key_with_lists(data: Any, target_key: str) -> None:
if isinstance(data, dict):
for key in list(data):
if key == target_key:
del data[key]
else:
remove_key_with_lists(data[key], target_key)
elif isinstance(data, list):
for item in data:
remove_key_with_lists(item, target_key)
for key in (
"position",
"rotation",
"iconPosition",
"icon",
"annotations",
"library",
"properties",
):
remove_key_with_lists(graph, key)
return graph
def _port_declaration(
port: dict[str, Any], direction: str, indent: int, macros: dict[str, str]
) -> str:
indentation = "\t" * indent
port_name = identifier(port["name"])
dimensions = _port_dimensions(port, port_name)
if port.get("type") == "power":
return expand_bevalues(
f"{indentation}BondPort {port_name}{dimensions};", macros
)
port_type = modelica_type(port.get("valueType", "real"))
attributes = _quantity_unit_attributes(port)
declaration = (
f"{indentation}{direction} {port_type} {port_name}{dimensions}{attributes};"
)
return expand_bevalues(declaration, macros)
def _fixed_dimensions(item: dict[str, Any]) -> str:
dimensions = item.get("dimensions", {})
rows = max(1, int(dimensions.get("rows", 1)))
columns = max(1, int(dimensions.get("columns", 1)))
if rows == columns == 1:
return ""
if columns == 1:
return f"[{rows}]"
return f"[{rows},{columns}]"
def _port_dimensions(port: dict[str, Any], port_name: str) -> str:
fixed = _fixed_dimensions(port)
if not port.get("multipleConnections", False):
return fixed
entries = [f"${port_name}_N$"]
if fixed:
entries.extend(fixed[1:-1].split(","))
return f"[{','.join(entries)}]"
def _quantity_unit_attributes(item: dict[str, Any]) -> str:
attributes = []
for key in ("quantity", "unit"):
value = str(item.get(key, "")).strip()
if value:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
attributes.append(f'{key}="{escaped}"')
return f"({', '.join(attributes)})" if attributes else ""
def _endpoint_expression(
endpoint: dict[str, Any],
owner: dict[str, Any],
blocks: dict[str, dict[str, Any]],
junctions: dict[str, dict[str, Any]],
endpoint_indices: dict[tuple[str, str, str], int],
) -> str:
if "junction" in endpoint:
junction_id = endpoint["junction"]
if junction_id not in junctions:
raise ValueError(f"Connection references unknown junction {junction_id!r}")
return _junction_name(junction_id)
if "interface" in endpoint:
port = _find_port(owner, endpoint["interface"])
expression = identifier(port["name"])
return _index_array_endpoint(
expression, port, ("interface", owner["id"], port["id"]), endpoint_indices
)
block_id = endpoint.get("block")
port_id = endpoint.get("port")
block = blocks.get(block_id)
if block is None:
raise ValueError(f"Connection references unknown block {block_id!r}")
port = _find_port(block, port_id)
expression = f"{identifier(block['name'])}.{identifier(port['name'])}"
return _index_array_endpoint(
expression, port, ("block", block_id, port_id), endpoint_indices
)
def _index_array_endpoint(
expression: str,
port: dict[str, Any],
key: tuple[str, str, str],
endpoint_indices: dict[tuple[str, str, str], int],
) -> str:
if not port.get("multipleConnections", False):
return expression
endpoint_indices[key] = endpoint_indices.get(key, 0) + 1
return f"{expression}[{endpoint_indices[key]}]"
def _block_connection_data(
graph: dict[str, Any], block_id: str
) -> tuple[dict[str, int], dict[str, list[float]]]:
counts: dict[str, int] = {}
signs: dict[str, list[float]] = {}
for connection in graph.get("connections", []):
for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
if endpoint.get("block") == block_id and endpoint.get("port"):
port_id = endpoint["port"]
counts[port_id] = counts.get(port_id, 0) + 1
signs.setdefault(port_id, []).append(sign)
return counts, signs
def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]:
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return {}
counts: dict[str, int] = {}
for connection in implementation.get("graph", {}).get("connections", []):
for endpoint in (connection.get("source", {}), connection.get("target", {})):
if endpoint.get("interface"):
port_id = endpoint["interface"]
counts[port_id] = counts.get(port_id, 0) + 1
return counts
def _interface_connection_signs(component: dict[str, Any]) -> dict[str, list[float]]:
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return {}
signs: dict[str, list[float]] = {}
for connection in implementation.get("graph", {}).get("connections", []):
for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
port_id = endpoint.get("interface")
if port_id:
signs.setdefault(port_id, []).append(sign)
return signs
def _port_connection_macros(
component: dict[str, Any],
connection_counts: dict[str, int],
connection_signs: dict[str, list[float]],
) -> dict[str, str]:
macros: dict[str, str] = {}
interface = component.get("interface", {})
for port in interface.get("ports", []):
if port.get("multipleConnections", False):
macro_name = identifier(port["name"])
port_id = port["id"]
macros[f"{macro_name}_N"] = str(connection_counts.get(port_id, 0))
signs = connection_signs.get(port_id, [])
macros[f"{macro_name}_S"] = "{" + ", ".join(
f"{sign:.1f}" for sign in signs
) + "}"
return macros
def _contains_power_port(component: dict[str, Any]) -> bool:
if any(
port.get("type") == "power"
for port in component.get("interface", {}).get("ports", [])
):
return True
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return False
return any(
_contains_power_port(block)
for block in implementation.get("graph", {}).get("blocks", [])
)
def expand_bevalues(text: str, values: dict[str, str]) -> str:
"""Replace BEdit ``$name$`` macros and reject unresolved composer values."""
def replace(match: re.Match[str]) -> str:
name = match.group(1)
if name not in values:
raise ValueError(f"Unknown BEdit value ${name}$")
return values[name]
return _BEVALUE_PATTERN.sub(replace, text)
def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]:
interface = component.get("interface", {})
for port in interface.get("ports", []):
if port.get("id") == port_id:
return port
raise ValueError(
f"Component {component.get('name', component.get('id', '?'))!r} "
f"has no port {port_id!r}"
)
def modelica_type(value: str) -> str:
normalized = str(value).strip().lower()
if normalized in {"signal", "signal array"}:
return "Real"
return _MODELICA_TYPES.get(normalized, identifier(str(value)))
def identifier(value: str) -> str:
"""Return a safe unquoted Modelica identifier."""
normalized = re.sub(r"[^A-Za-z0-9_]", "_", str(value).strip())
if not normalized:
raise ValueError("Modelica names cannot be empty")
if normalized[0].isdigit():
normalized = f"model_{normalized}"
return normalized
def model_name_for(component: dict[str, Any]) -> str:
"""Return the generated Modelica type name for a component."""
return identifier(f"m_{component['name']}")
def _junction_name(junction_id: str) -> str:
return identifier(f"junction_{junction_id}")

View File

@@ -0,0 +1,639 @@
import json
import logging
import os
import re
import shlex
import shutil
import socket
import tempfile
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from queue import Queue
from threading import Event, Lock, Thread, current_thread
from typing import Any
from bedit.core.application_log import get_logger
from bedit.core.simulation.results import (
SimulationExecutionResult,
load_openmodelica_csv,
)
log = get_logger(__name__)
ResultCallback = Callable[[Any], None]
ErrorCallback = Callable[[Exception], None]
@dataclass(frozen=True)
class _Request:
description: str
operation: Callable[[Any, Path], Any]
callback: ResultCallback | None
error_callback: ErrorCallback | None
@dataclass(frozen=True)
class SimulationProgress:
phase: str
current_step_size: float
time: float
progress: int
@dataclass(frozen=True)
class SimulationMessage:
stream: str
type: str
text: str
@dataclass(frozen=True)
class ModelBuildResult:
"""OMC's model check summary and subsequent build response."""
check_summary: str
build_result: Any
diagnostics: str = ""
class OpenModelicaInterface:
"""Asynchronous, persistent interface to one OpenModelica session.
The worker and OMC session are created lazily for the first request. Callbacks
execute on background threads and must not manipulate Qt widgets directly.
"""
def __init__(self, executable_path: str = "") -> None:
self._executable_path = executable_path
self._lifecycle_lock = Lock()
self._queue: Queue[_Request | None] | None = None
self._worker: Thread | None = None
self._temp_dir: Path | None = None
@property
def executable_path(self) -> str:
return self._executable_path
def configure(self, executable_path: str) -> None:
"""Use a new executable path for subsequent requests."""
if executable_path == self._executable_path:
return
self.shutdown(wait=True)
self._executable_path = executable_path
def get_version(
self,
callback: ResultCallback | None = None,
error_callback: ErrorCallback | None = None,
) -> None:
"""Request the OpenModelica version without blocking the caller."""
self.send_expression("getVersion()", callback, error_callback)
def build_model(
self,
model: str,
model_name: str,
callback: ResultCallback | None = None,
error_callback: ErrorCallback | None = None,
) -> None:
"""Load and build one composed model as an ordered worker operation."""
def operation(omc, _temp_dir: Path):
capture = _LogCapture()
ompython_logger = logging.getLogger("OMPython")
previous_propagate = ompython_logger.propagate
ompython_logger.addHandler(capture)
ompython_logger.propagate = False
try:
unit_checking = omc.sendExpression(
'setCommandLineOptions("--unitChecking")'
)
if unit_checking is not True:
raise RuntimeError("OpenModelica could not enable unit checking")
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
if loaded is not True:
raise RuntimeError("OpenModelica could not load the composed model")
diagnostics = [_read_omc_diagnostics(omc)]
check_summary = omc.sendExpression(f"checkModel({model_name})")
diagnostics.append(_read_omc_diagnostics(omc))
if not isinstance(check_summary, str) or not check_summary.strip():
raise RuntimeError(
"OpenModelica did not return a model-check summary"
)
raw_build_result = omc.sendExpression(
f"buildModel({model_name})", parsed=False
)
build_result = _parse_modelica_string_array(raw_build_result)
diagnostics.append(_read_omc_diagnostics(omc))
diagnostics.extend(capture.messages)
diagnostic_text = "\n".join(
item.strip()
for item in diagnostics
if isinstance(item, str) and item.strip()
)
return ModelBuildResult(check_summary, build_result, diagnostic_text)
finally:
ompython_logger.removeHandler(capture)
ompython_logger.propagate = previous_propagate
self._submit("build model", operation, callback, error_callback)
def run_model(
self,
executable: str,
arguments: list[str],
progress_callback: Callable[[SimulationProgress], None] | None = None,
message_callback: Callable[[SimulationMessage], None] | None = None,
callback: ResultCallback | None = None,
error_callback: ErrorCallback | None = None,
) -> None:
"""Run a built model and consume its XML/TCP status stream."""
def operation(omc, temp_dir: Path):
return _run_model_with_tcp(
omc,
executable,
arguments,
temp_dir,
progress_callback,
message_callback,
)
self._submit("run simulation", operation, callback, error_callback)
def send_expression(
self,
expression: str,
callback: ResultCallback | None = None,
error_callback: ErrorCallback | None = None,
*,
parsed: bool = True,
) -> None:
"""Queue an OMC expression for ordered execution on the worker thread."""
def operation(omc, _temp_dir: Path):
return omc.sendExpression(expression, parsed=parsed)
self._submit(expression, operation, callback, error_callback)
def _submit(
self,
description: str,
operation: Callable[[Any, Path], Any],
callback: ResultCallback | None,
error_callback: ErrorCallback | None,
) -> None:
request = _Request(description, operation, callback, error_callback)
self._ensure_worker().put(request)
def shutdown(self, *, wait: bool = True) -> None:
"""Ask the worker to close its OMC session after queued requests."""
with self._lifecycle_lock:
worker = self._worker
queue = self._queue
self._worker = None
self._queue = None
if queue is not None:
queue.put(None)
if wait and worker is not None and worker is not current_thread():
worker.join()
if worker is None:
self._cleanup_temp_dir()
def __del__(self) -> None:
"""Best-effort fallback; normal application shutdown is explicit."""
try:
self.shutdown(wait=False)
except Exception:
pass
def _ensure_worker(self) -> Queue[_Request | None]:
with self._lifecycle_lock:
if self._worker is not None and self._worker.is_alive():
return self._queue
temp_dir = self._ensure_temp_dir_locked()
queue: Queue[_Request | None] = Queue()
worker = Thread(
target=self._worker_main,
args=(queue, self._executable_path, temp_dir),
name="bedit-openmodelica",
daemon=True,
)
self._queue = queue
self._worker = worker
worker.start()
return queue
def _worker_main(
self,
queue: Queue[_Request | None],
executable_path: str,
temp_dir: Path,
) -> None:
omc = None
try:
while (request := queue.get()) is not None:
try:
if omc is None:
omc = _create_session(executable_path)
changed_directory = omc.sendExpression(
f"cd({json.dumps(str(temp_dir))})"
)
if not changed_directory:
raise RuntimeError(
f"OpenModelica could not use {str(temp_dir)!r}"
)
result = request.operation(omc, temp_dir)
except Exception as error:
if request.error_callback is None:
log.exception(
"OpenModelica request failed: %s", request.description
)
else:
_deliver_callback(request.error_callback, error)
else:
if request.callback is not None:
_deliver_callback(request.callback, result)
finally:
transport_files = _ompython_transport_files(omc)
if omc is not None:
try:
omc.sendExpression("quit()")
except Exception:
log.debug("Could not close OpenModelica session", exc_info=True)
for path in transport_files:
try:
path.unlink(missing_ok=True)
except OSError:
log.debug("Could not remove OMPython file %s", path, exc_info=True)
self._cleanup_temp_dir(temp_dir)
def _ensure_temp_dir_locked(self) -> Path:
if self._temp_dir is None:
self._temp_dir = Path(tempfile.mkdtemp(prefix="bedit-openmodelica-"))
return self._temp_dir
def _cleanup_temp_dir(self, expected: Path | None = None) -> None:
with self._lifecycle_lock:
if expected is not None and self._temp_dir != expected:
temp_dir = expected
else:
temp_dir = self._temp_dir
self._temp_dir = None
if temp_dir is not None:
shutil.rmtree(temp_dir, ignore_errors=True)
def _read_omc_diagnostics(omc) -> str:
"""Read OMC diagnostics without sending multiline text through OMPython's parser."""
raw = omc.sendExpression("getErrorString()", parsed=False)
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
return ""
text = raw.strip()
if not text:
return ""
try:
decoded = json.loads(text)
except json.JSONDecodeError:
return text
return decoded if isinstance(decoded, str) else text
class _LogCapture(logging.Handler):
"""Collect dependency log messages for forwarding through BEdit's logger."""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
self.messages: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.messages.append(record.getMessage())
def _parse_modelica_string_array(raw: Any) -> tuple[str, ...]:
"""Parse the string array returned by ``buildModel`` in raw mode."""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
values = tuple(
json.loads(token)
for token in re.findall(r'"(?:\\.|[^"\\])*"', raw)
)
if not values:
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
return values
def _deliver_callback(callback: Callable[[Any], None], value: Any) -> None:
try:
callback(value)
except Exception:
log.exception("OpenModelica callback failed")
def _create_session(executable_path: str):
from OMPython import OMCSessionZMQ
return OMCSessionZMQ(omhome=_openmodelica_home(executable_path))
def _run_model_with_tcp(
omc,
executable: str,
arguments: list[str],
temp_dir: Path,
progress_callback: Callable[[SimulationProgress], None] | None,
message_callback: Callable[[SimulationMessage], None] | None,
) -> SimulationExecutionResult:
executable_path = Path(executable)
executable_command = executable
if not executable_path.is_absolute() and executable_path.parent == Path("."):
executable_command = f"./{executable}"
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
server.listen(1)
server.settimeout(0.25)
port = server.getsockname()[1]
command = shlex.join([
executable_command,
*arguments,
f"-port={port}",
"-logFormat=xmltcp",
])
output_path = temp_dir / "simulation-output.txt"
command_finished = Event()
reader_finished = Event()
reader_errors: list[Exception] = []
def read_progress() -> None:
try:
connection = _accept_simulation_connection(server, command_finished)
with connection, connection.makefile(
"r", encoding="utf-8"
) as stream:
for line in stream:
_handle_simulation_xml(
line, progress_callback, message_callback
)
except Exception as error:
reader_errors.append(error)
finally:
reader_finished.set()
reader = Thread(
target=read_progress,
name="bedit-simulation-progress",
daemon=True,
)
reader.start()
log.info("Starting simulation through OpenModelica: %s", command)
try:
return_code = omc.sendExpression(
f"system({json.dumps(command)}, {json.dumps(str(output_path))})"
)
finally:
command_finished.set()
if not reader_finished.wait(20.0):
raise TimeoutError("Simulation progress connection did not close")
if reader_errors:
raise reader_errors[0]
if return_code != 0:
output = output_path.read_text(errors="replace") if output_path.exists() else ""
detail = f": {output.strip()}" if output.strip() else ""
raise RuntimeError(
f"Simulation process exited with status {return_code}{detail}"
)
result_path = temp_dir / f"{Path(executable).stem}_res.csv"
if not result_path.is_file():
raise RuntimeError(
f"OpenModelica did not create the expected result file {result_path.name!r}"
)
data = load_openmodelica_csv(result_path)
init_path = temp_dir / f"{Path(executable).stem}_init.xml"
csv_column_count = len(data)
data = _add_openmodelica_alias_signals(init_path, data)
signal_metadata = _load_openmodelica_signal_metadata(init_path, data)
log.info(
"Loaded %d result columns, restored %d OMC aliases, and found %d "
"unit definitions from %s",
csv_column_count,
len(data) - csv_column_count,
len(signal_metadata),
result_path.name,
)
return SimulationExecutionResult(
return_code=int(return_code),
result_file=str(result_path),
data=data,
signal_metadata=signal_metadata,
)
def _add_openmodelica_alias_signals(
path: Path, data: dict[str, list[float]]
) -> dict[str, list[float]]:
"""Restore result aliases exactly as described by OMC's init XML."""
try:
root = ET.parse(path).getroot()
except (OSError, ET.ParseError) as error:
raise RuntimeError(
f"Could not read OpenModelica aliases from {path.name}: {error}"
) from error
variables: dict[str, dict[str, str]] = {}
for scalar in root.findall(".//ScalarVariable"):
name = scalar.get("name", "")
if not name:
continue
value = next(iter(scalar), None)
variables[name] = {
"alias": scalar.get("alias", "noAlias"),
"target": scalar.get("aliasVariable", ""),
"variability": scalar.get("variability", ""),
"start": value.get("start", "") if value is not None else "",
}
restored = {name: list(values) for name, values in data.items()}
sample_count = len(next(iter(data.values()), []))
def resolve(name: str, visited: set[str] | None = None) -> list[float] | None:
if name in restored:
return restored[name]
variable = variables.get(name)
if variable is None:
return None
visited = set() if visited is None else visited
if name in visited:
return None
visited.add(name)
alias_kind = variable["alias"]
target = variable["target"]
if alias_kind in {"alias", "negatedAlias"} and target:
values = resolve(target, visited)
if values is None:
return None
return [-value for value in values] if alias_kind == "negatedAlias" else list(values)
if variable["variability"] not in {"parameter", "constant"}:
return None
try:
value = float(variable["start"])
except ValueError:
return None
return [value] * sample_count
for name, variable in variables.items():
if variable["alias"] not in {"alias", "negatedAlias"} or name in restored:
continue
values = resolve(name)
if values is not None:
restored[name] = values
return restored
def _load_openmodelica_signal_metadata(
path: Path, signal_names: dict[str, list[float]]
) -> dict[str, dict[str, str]]:
"""Read units reported by OMC in its generated initialization XML."""
try:
root = ET.parse(path).getroot()
except (OSError, ET.ParseError) as error:
raise RuntimeError(
f"Could not read OpenModelica unit metadata from {path.name}: {error}"
) from error
variables: dict[str, tuple[dict[str, str], str]] = {}
for scalar in root.findall(".//ScalarVariable"):
name = scalar.get("name", "")
if not name:
continue
value = next(iter(scalar), None)
metadata: dict[str, str] = {}
if value is not None:
for key in ("unit", "displayUnit", "quantity"):
attribute = value.get(key, "")
if attribute:
metadata[key] = attribute
description = scalar.get("description", "")
if description:
metadata["description"] = description
variables[name] = (metadata, scalar.get("aliasVariable", ""))
def resolve(name: str, visited: set[str] | None = None) -> dict[str, str]:
metadata, alias = variables.get(name, ({}, ""))
if metadata.get("unit") or not alias:
return dict(metadata)
visited = set() if visited is None else visited
if name in visited:
return dict(metadata)
visited.add(name)
inherited = resolve(alias, visited)
return {**inherited, **metadata}
metadata: dict[str, dict[str, str]] = {}
for name in signal_names:
if name not in variables:
continue
resolved = resolve(name)
if resolved:
metadata[name] = resolved
if "time" in signal_names:
metadata["time"] = {"unit": "s", "quantity": "Time"}
return metadata
def _accept_simulation_connection(
server: socket.socket, command_finished: Event
) -> socket.socket:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
try:
connection, _address = server.accept()
return connection
except socket.timeout:
if command_finished.is_set():
raise RuntimeError(
"Simulation command finished before opening its progress connection"
)
raise TimeoutError("Simulation did not connect to the progress server")
def _handle_simulation_xml(
line: str,
progress_callback: Callable[[SimulationProgress], None] | None,
message_callback: Callable[[SimulationMessage], None] | None,
) -> None:
text = line.strip()
if not text:
return
try:
element = ET.fromstring(text)
except ET.ParseError:
log.warning("Invalid simulation status XML: %s", text)
return
if element.tag == "status" and progress_callback is not None:
_deliver_callback(
progress_callback,
SimulationProgress(
phase=element.get("phase", ""),
current_step_size=float(element.get("currentStepSize", 0)),
time=float(element.get("time", 0)),
progress=int(float(element.get("progress", 0))),
),
)
elif element.tag == "message":
message = SimulationMessage(
stream=element.get("stream", ""),
type=element.get("type", ""),
text=element.get("text", ""),
)
log.info("OpenModelica %s: %s", message.stream, message.text)
if message_callback is not None:
_deliver_callback(message_callback, message)
def _ompython_transport_files(omc) -> set[Path]:
"""Return only the log and port files owned by this OMPython session."""
if omc is None:
return set()
process = getattr(omc, "omc_process", None)
if process is None:
return set()
files: set[Path] = set()
temp_dir = getattr(process, "_temp_dir", None)
file_base = getattr(process, "_omc_filebase", None)
if temp_dir is not None and file_base:
files.add(Path(temp_dir) / f"{file_base}.log")
try:
port_file = process._get_portfile_path()
except Exception:
port_file = None
if port_file is not None:
files.add(Path(port_file))
return files
def _openmodelica_home(executable_path: str) -> str | None:
"""Convert an optional omc executable path to the home expected by OMPython."""
if not executable_path.strip():
return None
path = Path(os.path.expandvars(executable_path)).expanduser()
if path.name.lower() in {"omc", "omc.exe"}:
return str(path.parent.parent)
return str(path)

View File

@@ -0,0 +1,237 @@
import csv
import json
import zlib
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from uuid import uuid4
import msgpack
RESULTS_FORMAT = "bedit-simulation-results"
RESULTS_VERSION = 1
@dataclass
class SimulationTrace:
"""One plottable series; samples can be filled by a future result importer."""
name: str
x_values: list[float] = field(default_factory=list)
y_values: list[float] = field(default_factory=list)
x_label: str = "time"
y_label: str = ""
unit: str = ""
properties: dict[str, Any] = field(default_factory=dict)
@dataclass
class SimulationGraph:
"""One graph workspace tab and its configured traces."""
id: str = field(default_factory=lambda: str(uuid4()))
title: str = "Graph 1"
x_axis: str = "time"
traces: list[SimulationTrace] = field(default_factory=list)
@dataclass
class SimulationResults:
"""Serializable state displayed by the standalone simulation window."""
model_name: str = ""
status: dict[str, Any] = field(default_factory=dict)
messages: list[dict[str, str]] = field(default_factory=list)
data: dict[str, list[float]] = field(default_factory=dict)
signal_metadata: dict[str, dict[str, str]] = field(default_factory=dict)
graphs: list[SimulationGraph] = field(
default_factory=lambda: [SimulationGraph()]
)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"format": RESULTS_FORMAT,
"version": RESULTS_VERSION,
"modelName": self.model_name,
"status": dict(self.status),
"messages": [dict(message) for message in self.messages],
"data": {name: list(values) for name, values in self.data.items()},
"signalMetadata": {
name: dict(metadata)
for name, metadata in self.signal_metadata.items()
},
"graphs": [asdict(graph) for graph in self.graphs],
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SimulationResults":
if data.get("format") != RESULTS_FORMAT:
raise ValueError("Not a BEdit simulation-results file")
if data.get("version") != RESULTS_VERSION:
raise ValueError(f"Unsupported simulation-results version: {data.get('version')!r}")
try:
graphs = [
SimulationGraph(
id=str(graph["id"]),
title=str(graph["title"]),
x_axis=str(graph.get("x_axis", "time")),
traces=[
SimulationTrace(**trace) for trace in graph.get("traces", [])
],
)
for graph in data.get("graphs", [])
]
return cls(
model_name=str(data.get("modelName", "")),
status=dict(data.get("status", {})),
messages=[dict(message) for message in data.get("messages", [])],
data={
str(name): [float(value) for value in values]
for name, values in dict(data.get("data", {})).items()
},
signal_metadata={
str(name): {
str(key): str(value)
for key, value in dict(metadata).items()
}
for name, metadata in dict(
data.get("signalMetadata", {})
).items()
},
graphs=graphs,
metadata=dict(data.get("metadata", {})),
)
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Malformed simulation-results data") from error
@dataclass(frozen=True)
class SimulationExecutionResult:
"""Completed process information delivered before its temp files disappear."""
return_code: int
result_file: str
data: dict[str, list[float]]
signal_metadata: dict[str, dict[str, str]] = field(default_factory=dict)
def load_openmodelica_csv(path: str | Path) -> dict[str, list[float]]:
"""Read an OpenModelica CSV result as one numeric array per column."""
source = Path(path)
try:
with source.open(newline="", encoding="utf-8") as file:
reader = csv.reader(file)
headers = next(reader)
if not headers or any(not header for header in headers):
raise ValueError("The result CSV has an invalid header")
if len(set(headers)) != len(headers):
raise ValueError("The result CSV contains duplicate column names")
columns = {header: [] for header in headers}
for row_number, row in enumerate(reader, start=2):
if len(row) != len(headers):
raise ValueError(
f"Result CSV row {row_number} has {len(row)} values; "
f"expected {len(headers)}"
)
for header, value in zip(headers, row, strict=True):
columns[header].append(float(value))
except OSError as error:
raise ValueError(f"Could not read OpenModelica results: {error}") from error
except StopIteration as error:
raise ValueError("The OpenModelica result CSV is empty") from error
except ValueError as error:
if str(error).startswith(("The result CSV", "Result CSV")):
raise
raise ValueError(f"The OpenModelica result CSV is not numeric: {error}") from error
return columns
class JsonSimulationResultsSerializer:
@staticmethod
def load(path: Path) -> SimulationResults:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"Could not read simulation results: {error}") from error
if not isinstance(data, dict):
raise ValueError("Simulation-results root must be an object")
return SimulationResults.from_dict(data)
@staticmethod
def save(results: SimulationResults, path: Path) -> None:
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_text(
json.dumps(results.to_dict(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
temporary_path.replace(path)
class BerSimulationResultsSerializer:
"""Compressed MessagePack serializer for binary simulation results."""
MAGIC = b"BER\x00"
VERSION = 1
@classmethod
def load(cls, path: Path) -> SimulationResults:
try:
payload = path.read_bytes()
except OSError as error:
raise ValueError(f"Could not read simulation results: {error}") from error
header = cls.MAGIC + bytes([cls.VERSION])
if not payload.startswith(header):
raise ValueError("This is not a supported BEdit binary results file")
try:
data = msgpack.unpackb(
zlib.decompress(payload[len(header) :]), raw=False
)
except (ValueError, zlib.error, msgpack.exceptions.MsgpackException) as error:
raise ValueError("The BEdit binary results file is damaged") from error
if not isinstance(data, dict):
raise ValueError("The BEdit binary results file has an invalid root value")
return SimulationResults.from_dict(data)
@classmethod
def save(cls, results: SimulationResults, path: Path) -> None:
packed = msgpack.packb(results.to_dict(), use_bin_type=True)
payload = cls.MAGIC + bytes([cls.VERSION]) + zlib.compress(packed, level=9)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_bytes(payload)
temporary_path.replace(path)
class SimulationResultsSerializer:
"""Select JSON or compressed MessagePack based on the file extension."""
@staticmethod
def load(path: str | Path) -> SimulationResults:
target = Path(path)
serializer = (
BerSimulationResultsSerializer
if target.suffix.lower() == ".ber"
else JsonSimulationResultsSerializer
)
return serializer.load(target)
@staticmethod
def save(results: SimulationResults, path: str | Path) -> None:
target = Path(path)
serializer = (
BerSimulationResultsSerializer
if target.suffix.lower() == ".ber"
else JsonSimulationResultsSerializer
)
serializer.save(results, target)
def save_simulation_results(path: str | Path, results: SimulationResults) -> None:
SimulationResultsSerializer.save(results, path)
def load_simulation_results(path: str | Path) -> SimulationResults:
return SimulationResultsSerializer.load(path)

View File

@@ -0,0 +1,196 @@
from collections.abc import Callable
from typing import Any
from bedit.core.application_log import get_logger
from bedit.core.bond_graph import infer_causality
from bedit.core.simulation.composer import compose_graph
from bedit.core.simulation.openmodelica import (
ErrorCallback,
ModelBuildResult,
OpenModelicaInterface,
ResultCallback,
SimulationMessage,
SimulationProgress,
)
log = get_logger(__name__)
class Simulation:
"""Application-owned composition state and OpenModelica interface."""
def __init__(self, *, openmodelica_path: str = "") -> None:
self.state: dict[str, Any] = {}
self.last_composition_input: dict[str, Any] | None = None
self.last_composition_output: str | None = None
self.id_list: dict[str, Any] = {}
self.model_name: str | None = None
self._openmodelica_path = openmodelica_path
self.openmodelica = OpenModelicaInterface(openmodelica_path)
self.model_path: str | None = None
self.simulation_progress: SimulationProgress | None = None
@property
def openmodelica_path(self) -> str:
return self._openmodelica_path
@openmodelica_path.setter
def openmodelica_path(self, value: str) -> None:
self._openmodelica_path = value
self.openmodelica.configure(value)
def compose(
self,
graph: dict[str, Any],
callback: Callable[[str], None] | None = None,
error_callback: ErrorCallback | None = None,
message_callback: Callable[[SimulationMessage], None] | None = None,
) -> None:
"""Compose and retain the active graph's Modelica representation."""
self.model_path = None
self.compose_source(graph)
def _model_compiled(result):
if not isinstance(result, ModelBuildResult):
failure = RuntimeError(
f"OpenModelica returned an invalid build response: {result!r}"
)
if error_callback is not None:
error_callback(failure)
else:
log.error("%s", failure)
return
log.info("%s", result.check_summary.strip())
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=result.check_summary.strip(),
)
)
if result.diagnostics:
log.warning("OpenModelica build diagnostics:\n%s", result.diagnostics)
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="warning",
text=result.diagnostics,
)
)
log.info("Compiling OK: %s", result.build_result)
try:
self.model_path = str(result.build_result[0])
except (IndexError, TypeError) as error:
failure = RuntimeError(
f"OpenModelica returned an invalid build result: {result!r}"
)
failure.__cause__ = error
if error_callback is not None:
error_callback(failure)
else:
log.error("%s", failure)
return
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=f"Compiling OK: {result.build_result}",
)
)
if callback is not None:
callback(self.model_path)
self.openmodelica.build_model(
self.last_composition_output,
self.model_name,
_model_compiled,
error_callback,
)
def compose_source(self, graph: dict[str, Any]) -> tuple[str, str]:
"""Compose Modelica source without asking OpenModelica to build it."""
infer_causality(graph)
result = compose_graph(graph)
self.last_composition_input = result.graph
self.id_list = result.objects_by_id
self.last_composition_output = result.modelica
self.model_name = result.model_name
# log.info("Composed OpenModelica model:\n%s", result.modelica)
return result.model_name, result.modelica
def run_simulation(
self,
graph: dict[str, Any],
progress_callback: Callable[[SimulationProgress], None] | None = None,
message_callback: Callable[[SimulationMessage], None] | None = None,
callback: ResultCallback | None = None,
error_callback: ErrorCallback | None = None,
) -> None:
"""Compose, build, and asynchronously run the current graph."""
self.simulation_progress = None
def report_progress(progress: SimulationProgress) -> None:
self.simulation_progress = progress
if progress_callback is not None:
progress_callback(progress)
def run_model(model_path: str) -> None:
try:
arguments = self.build_simulation_arguments()
except Exception as error:
if error_callback is not None:
error_callback(error)
else:
log.exception("Could not prepare simulation arguments")
return
self.openmodelica.run_model(
model_path,
arguments,
report_progress,
message_callback,
callback,
error_callback,
)
self.compose(graph, run_model, error_callback, message_callback)
def get_progress(self) -> SimulationProgress | None:
"""Return the most recently received simulation status."""
return self.simulation_progress
def shutdown(self, *, wait: bool = True) -> None:
"""Close OpenModelica and clean its generated working directory."""
self.openmodelica.shutdown(wait=wait)
self.model_path = None
def build_simulation_arguments(self) -> list[str]:
opts = self.last_composition_input["implementation"]["graph"].get(
"simulation", {}
)
start_time = float(opts.get("startTime", 0.0))
stop_time = float(opts.get("stopTime", 1.0))
interval_mode = opts.get("intervalMode", "numberOfIntervals")
interval_time = float(opts.get("intervalTime", 0.002))
if interval_mode == "numberOfIntervals":
intervals = int(opts.get("numberOfIntervals", 500))
if intervals <= 0:
raise ValueError("Number of simulation intervals must be positive")
interval_time = (stop_time - start_time) / intervals
if interval_time <= 0:
raise ValueError("Simulation interval must be positive")
arguments = [
"-outputFormat=csv",
f"-startTime={start_time}",
f"-stopTime={stop_time}",
f"-stepSize={interval_time}",
]
log.info("Running model with: %s", arguments)
return arguments

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
{
"keywords": [
"algorithm", "and", "annotation", "block", "break", "class", "connect",
"connector", "constant", "constrainedby", "der", "discrete", "each", "else",
"elseif", "elsewhen", "encapsulated", "end", "enumeration", "equation",
"expandable", "extends", "external", "false", "final", "flow", "for",
"function", "if", "import", "impure", "in", "initial", "inner", "input",
"loop", "model", "not", "operator", "or", "outer", "output", "package",
"parameter", "partial", "protected", "public", "pure", "record", "redeclare",
"replaceable", "return", "stream", "then", "true", "type", "when", "while",
"within"
],
"types": ["Boolean", "Integer", "Real", "String"],
"builtins": [
"abs", "acos", "actualStream", "asin", "assert", "atan", "atan2", "cardinality",
"ceil", "change", "cos", "cosh", "delay", "div", "edge", "exp", "floor",
"homotopy", "inStream", "integer", "log", "log10", "max", "min", "mod",
"noEvent", "pre", "reinit", "rem", "sample", "semiLinear", "sign", "sin",
"sinh", "smooth", "sqrt", "sum", "tan", "tanh", "terminal", "terminate"
],
"bevalues": []
}

View File

@@ -1,13 +0,0 @@
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

@@ -1,741 +0,0 @@
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.port_types import PortTypeRegistry
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")
source_port = self._port_for_endpoint(source, "source")
target_port = self._port_for_endpoint(target, "target")
if source_port is None or target_port is None:
raise ValueError("A connection endpoint no longer exists")
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(
f"Cannot connect {source_port.type!r} to {target_port.type!r}"
)
connection = Connection(str(uuid4()), source, target)
self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection)
)
return connection.id
def _port_for_endpoint(self, endpoint: Endpoint, role: str) -> Port | None:
owner = self.active_component
if owner is None:
return None
if endpoint.interface is not None:
ports = owner.inputs if role == "source" else owner.outputs
else:
component = owner.graph.blocks.get(endpoint.block or "")
if component is None:
return None
ports = component.outputs if role == "source" else component.inputs
return next((port for port in ports if port.id == (endpoint.interface or endpoint.port)), None)
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,
icon: Icon,
inputs: list[Port],
outputs: list[Port],
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,
"icon": component.icon.to_dict(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"show_subtree": component.show_subtree_in_library,
}
new = {
"name": name,
"icon": icon.to_dict(),
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"show_subtree": show_subtree,
}
if old != new:
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component_id)
candidate_component.name = name
candidate_component.icon = Icon.from_dict(icon.to_dict())
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate.validate()
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
def edit_component_ports(
self, component_id: str, inputs: list[Port], outputs: list[Port]
) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
input_ids = {port.id for port in inputs}
output_ids = {port.id for port in outputs}
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("An input cannot be removed or reoriented while connected")
if connection.source.block == component_id and connection.source.port not in output_ids:
raise ValueError("An output cannot be removed or reoriented while connected")
for connection in component.graph.connections.values():
if connection.source.interface and connection.source.interface not in input_ids:
raise ValueError("An interface input cannot be removed while connected")
if connection.target.interface and connection.target.interface not in output_ids:
raise ValueError("An interface output cannot be removed while connected")
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component_id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate.validate()
old = {
"name": component.name,
"icon": component.icon.to_dict(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"show_subtree": component.show_subtree_in_library,
}
new = {
**old,
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
}
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.from_dict(values["icon"])
component.inputs = [Port.from_dict(port) for port in values["inputs"]]
component.outputs = [Port.from_dict(port) for port in values["outputs"]]
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

@@ -1,368 +0,0 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from bedit.document.port_types import PortTypeRegistry
@dataclass
class Port:
id: str
name: str
x: float = 0.0
y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"properties": self.properties,
"type": self.type,
}
@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", {})),
type=str(data.get("type", "signal")),
)
@dataclass
class Icon:
shape: str = "rectangle"
fill: str = "#f4f4f4"
border: str = "#303030"
text: str = ""
width: float = 128.0
height: float = 128.0
elements: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.elements:
self.elements = [{
"type": "ellipse" if self.shape == "ellipse" else "rectangle",
"x": 32.0, "y": 32.0, "width": 64.0, "height": 64.0,
"fill": self.fill, "stroke": self.border, "lineWidth": 1.5,
"lineStyle": "solid", "cornerRadius": 5.0,
}]
if self.text:
self.elements.append({
"type": "text", "x": 40.0, "y": 40.0,
"width": 48.0, "height": 48.0,
"text": self.text, "color": "#202020", "fontSize": 12.0,
})
def to_dict(self) -> dict[str, Any]:
return {
"shape": self.shape,
"fill": self.fill,
"border": self.border,
"text": self.text,
"size": {"width": self.width, "height": self.height},
"elements": deepcopy(self.elements),
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {}
icon = cls(
shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")),
text=str(data.get("text", "")),
width=128.0,
height=128.0,
elements=deepcopy(data.get("elements", [])),
)
return icon
@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}
if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs):
raise ValueError(f"Component {owner.name} contains duplicate port IDs")
for port in (*owner.inputs, *owner.outputs):
PortTypeRegistry.get(port.type)
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")
source_port = next(p for p in owner.inputs if p.id == connection.source.interface)
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")
source_port = next(p for p in source.outputs if p.id == connection.source.port)
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")
target_port = next(p for p in owner.outputs if p.id == connection.target.interface)
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")
target_port = next(p for p in target.inputs if p.id == connection.target.port)
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(f"Connection {connection.id} joins incompatible port types")
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), port.type) for port in current.inputs],
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.outputs],
icon=Icon.from_dict(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

@@ -1,23 +0,0 @@
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 @@
"""Qt user interface and application adapters."""

View File

@@ -1,10 +1,12 @@
import sys
from collections.abc import Sequence
from pathlib import Path
from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication, QStyleFactory
from bedit.main_window import MainWindow
from bedit.gui.main_window import MainWindow
def apply_light_theme(app: QApplication) -> None:
@@ -38,14 +40,26 @@ def apply_light_theme(app: QApplication) -> None:
app.setPalette(palette)
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("BEdit")
app.setApplicationDisplayName("BEdit")
app.setOrganizationName("BEdit")
def _document_argument(arguments: Sequence[str]) -> Path | None:
"""Return the first positional application argument, if present."""
return next(
(Path(argument) for argument in arguments[1:] if not argument.startswith("-")),
None,
)
def main(argv: Sequence[str] | None = None) -> int:
QCoreApplication.setApplicationName("BEdit")
QCoreApplication.setOrganizationName("BEdit")
QCoreApplication.setApplicationVersion("0.1.0")
app = QApplication(list(sys.argv if argv is None else argv))
app.setApplicationDisplayName("BEdit")
apply_light_theme(app)
window = MainWindow()
document_path = _document_argument(app.arguments())
if document_path is not None:
window.open_document_path(document_path, check_unsaved=False)
window.show()
return app.exec()

View File

@@ -0,0 +1,26 @@
import logging
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QPlainTextEdit
class _LogEmitter(QObject):
lineReady = Signal(str)
class ApplicationLogHandler(logging.Handler):
"""Thread-safe bridge from Python logging into a Qt text view."""
def __init__(self, output: QPlainTextEdit) -> None:
super().__init__()
self.emitter = _LogEmitter(output)
self.emitter.lineReady.connect(output.appendPlainText)
self.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%H:%M:%S")
)
def emit(self, record: logging.LogRecord) -> None:
try:
self.emitter.lineReady.emit(self.format(record))
except Exception:
self.handleError(record)

View File

@@ -0,0 +1,5 @@
"""Qt-aware application controllers and undo commands."""
from bedit.gui.controllers.document import DocumentController
__all__ = ["DocumentController"]

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand
from bedit.document.model import Component, Connection, Port
from bedit.core.model import Annotation, Component, Connection, Junction, Port
class AddComponentCommand(QUndoCommand):
@@ -76,6 +76,107 @@ class AddConnectionCommand(QUndoCommand):
self.controller._remove_connection(self.owner_id, self.connection.id)
class SplitConnectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
original: Connection,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
super().__init__("Add connection junction")
self.controller, self.owner_id = controller, owner_id
self.original, self.junction = original, junction
self.first, self.second = first, second
def redo(self) -> None:
self.controller._split_connection(
self.owner_id, self.original.id, self.junction, self.first, self.second
)
def undo(self) -> None:
self.controller._restore_split_connection(
self.owner_id, self.original, self.junction.id, self.first.id, self.second.id
)
class AddAnnotationCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotation: Annotation) -> None:
super().__init__(f"Draw {annotation.kind}")
self.controller, self.owner_id, self.annotation = controller, owner_id, annotation
def redo(self) -> None:
self.controller._insert_annotation(self.owner_id, self.annotation)
def undo(self) -> None:
self.controller._remove_annotation(self.owner_id, self.annotation.id)
class EditGraphItemCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
item_kind: str,
item_id: str,
old: dict,
new: dict,
text: str,
) -> None:
super().__init__(text)
self.controller, self.owner_id = controller, owner_id
self.item_kind, self.item_id = item_kind, item_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_graph_item_data(self.owner_id, self.item_kind, self.item_id, self.new)
def undo(self) -> None:
self.controller._set_graph_item_data(self.owner_id, self.item_kind, self.item_id, self.old)
class EditSimulationSettingsCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, old: dict, new: dict) -> None:
super().__init__("Edit simulation settings")
self.controller, self.owner_id = controller, owner_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_simulation_settings(self.owner_id, self.new)
def undo(self) -> None:
self.controller._set_simulation_settings(self.owner_id, self.old)
class EditGraphParametersCommand(QUndoCommand):
def __init__(self, controller, old: dict, new: dict) -> None:
super().__init__("Edit graph parameters")
self.controller = controller
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_graph_parameters(self.new)
def undo(self) -> None:
self.controller._set_graph_parameters(self.old)
class DeleteAnnotationsCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotations: dict[str, Annotation]) -> None:
super().__init__("Delete annotations")
self.controller, self.owner_id, self.annotations = controller, owner_id, annotations
def redo(self) -> None:
for annotation_id in self.annotations:
self.controller._remove_annotation(self.owner_id, annotation_id)
def undo(self) -> None:
for annotation in self.annotations.values():
self.controller._insert_annotation(self.owner_id, annotation)
class ReplaceComponentCommand(QUndoCommand):
def __init__(self, controller, old: Component, new: Component) -> None:
super().__init__("Apply JSON changes")
@@ -151,6 +252,32 @@ class EditComponentAppearanceCommand(QUndoCommand):
self.controller._set_component_appearance(self.component_id, self.old)
class EditComponentPropertiesCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: dict, new: dict, text: str) -> None:
super().__init__(text)
self.controller, self.component_id = controller, component_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_component_properties(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_component_properties(self.component_id, self.old)
class EditComponentParametersCommand(QUndoCommand):
def __init__(self, controller, component_id: str, old: list, new: list) -> None:
super().__init__("Edit component parameters")
self.controller, self.component_id = controller, component_id
self.old, self.new = old, new
def redo(self) -> None:
self.controller._set_component_parameters(self.component_id, self.new)
def undo(self) -> None:
self.controller._set_component_parameters(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")
@@ -177,6 +304,20 @@ class RenameConnectionCommand(QUndoCommand):
self.controller._rename_connection(self.owner_id, self.connection_id, self.old)
class RenameDocumentCommand(QUndoCommand):
def __init__(self, controller, old: str, new: str) -> None:
super().__init__("Rename document")
self.controller = controller
self.old = old
self.new = new
def redo(self) -> None:
self.controller._rename_document(self.new)
def undo(self) -> None:
self.controller._rename_document(self.old)
class DeleteSelectionCommand(QUndoCommand):
def __init__(
self,
@@ -184,24 +325,21 @@ class DeleteSelectionCommand(QUndoCommand):
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
ports: 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
self.ports = ports
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},
{port.id for port in self.ports},
)
def undo(self) -> None:
@@ -209,8 +347,7 @@ class DeleteSelectionCommand(QUndoCommand):
self.owner_id,
self.blocks,
self.connections,
self.inputs,
self.outputs,
self.ports,
)
@@ -218,7 +355,7 @@ class PasteSelectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
) -> None:
@@ -234,7 +371,6 @@ class PasteSelectionCommand(QUndoCommand):
self.blocks,
self.connections,
[],
[],
)
def undo(self) -> None:
@@ -243,7 +379,6 @@ class PasteSelectionCommand(QUndoCommand):
set(self.blocks),
set(self.connections),
set(),
set(),
)
@@ -260,3 +395,14 @@ class EditTextDefinitionCommand(QUndoCommand):
def undo(self) -> None:
self.controller._set_text_definition(self.component_id, self.old)
def id(self) -> int:
return 1001
def mergeWith(self, other: QUndoCommand) -> bool: # noqa: N802
if not isinstance(other, EditTextDefinitionCommand):
return False
if other.component_id != self.component_id:
return False
self.new = other.new
return True

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""Application dialogs."""

View File

@@ -0,0 +1,34 @@
from PySide6.QtWidgets import QDialog, QMessageBox
from bedit.core.model import Component
from bedit.gui.graphics.icon_editor import IconEditorDialog
from bedit.gui.generated.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.component = component
self.edited_icon = component.icon
self.edited_ports = component.ports
self.ui.nameEdit.setText(component.name)
self.ui.editIconButton.clicked.connect(self.edit_icon)
self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library)
self.ui.showNameCheckBox.setChecked(bool(component.properties.get("showName", False)))
def edit_icon(self) -> None:
working = Component.from_dict(self.component.to_dict())
working.icon = self.edited_icon
working.ports = self.edited_ports
dialog = IconEditorDialog(working, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.edited_icon = dialog.icon
self.edited_ports = dialog.ports
def accept(self) -> None:
if not self.ui.nameEdit.text().strip():
QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.")
return
super().accept()

View File

@@ -0,0 +1,18 @@
from PySide6.QtWidgets import QDialog
from bedit.gui.generated.ui_connection_chooser_dialog import Ui_ConnectionChooserDialog
class ConnectionChooserDialog(QDialog):
def __init__(self, labels: list[str], default_index: int = 0, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_ConnectionChooserDialog()
self.ui.setupUi(self)
self.ui.connectionList.addItems(labels)
if labels:
self.ui.connectionList.setCurrentRow(max(0, min(default_index, len(labels) - 1)))
self.ui.buttonBox.button(self.ui.buttonBox.StandardButton.Ok).setEnabled(bool(labels))
@property
def selected_index(self) -> int:
return self.ui.connectionList.currentRow()

View File

@@ -0,0 +1,93 @@
from copy import deepcopy
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDialog, QStyledItemDelegate, QTreeWidgetItem
from bedit.core.model import Component
from bedit.gui.generated.ui_graph_parameters_dialog import Ui_GraphParametersDialog
COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole
PARAMETER_ID_ROLE = Qt.ItemDataRole.UserRole + 1
class ValueColumnDelegate(QStyledItemDelegate):
"""Allow editing only in the parameter value column."""
def createEditor(self, parent, option, index): # noqa: N802 (Qt API name)
parameter_id = index.siblingAtColumn(0).data(PARAMETER_ID_ROLE)
if index.column() != 2 or parameter_id is None:
return None
return super().createEditor(parent, option, index)
class GraphParametersDialog(QDialog):
"""Edit every parameter value in an active component subtree."""
def __init__(self, root: Component, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_GraphParametersDialog()
self.ui.setupUi(self)
self.ui.parameterTree.setItemDelegate(ValueColumnDelegate(self.ui.parameterTree))
self.setWindowTitle(f"Graph Parameters — {root.name}")
self._parameters = {
component.id: deepcopy(component.parameters)
for component in self._walk(root)
}
self._populate(root)
self.ui.parameterTree.expandAll()
self.ui.parameterTree.resizeColumnToContents(0)
self.ui.parameterTree.resizeColumnToContents(1)
@staticmethod
def _walk(component: Component):
yield component
if component.implementation_kind == "graph":
for child in component.graph.blocks.values():
yield from GraphParametersDialog._walk(child)
def _populate(self, root: Component) -> None:
self.ui.parameterTree.clear()
def add_component(component: Component, parent: QTreeWidgetItem | None) -> None:
item = QTreeWidgetItem([component.name, "", ""])
item.setData(0, COMPONENT_ID_ROLE, component.id)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if parent is None:
self.ui.parameterTree.addTopLevelItem(item)
else:
parent.addChild(item)
for parameter in self._parameters[component.id]:
parameter_item = QTreeWidgetItem(
[parameter.name, parameter.type, parameter.value]
)
parameter_item.setData(0, COMPONENT_ID_ROLE, component.id)
parameter_item.setData(0, PARAMETER_ID_ROLE, parameter.id)
parameter_item.setFlags(
parameter_item.flags() | Qt.ItemFlag.ItemIsEditable
)
item.addChild(parameter_item)
if component.implementation_kind == "graph":
for child in component.graph.blocks.values():
add_component(child, item)
add_component(root, None)
@property
def parameter_values(self) -> dict[str, dict[str, str]]:
values: dict[str, dict[str, str]] = {}
iterator = self.ui.parameterTree.invisibleRootItem()
def collect(parent: QTreeWidgetItem) -> None:
for index in range(parent.childCount()):
item = parent.child(index)
parameter_id = item.data(0, PARAMETER_ID_ROLE)
if parameter_id is not None:
component_id = item.data(0, COMPONENT_ID_ROLE)
values.setdefault(component_id, {})[parameter_id] = item.text(2)
collect(item)
collect(iterator)
return values

View File

@@ -1,6 +1,7 @@
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QCheckBox,
QFormLayout,
QLineEdit,
QMessageBox,
@@ -11,7 +12,15 @@ from PySide6.QtWidgets import (
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:
def __init__(
self,
title: str,
name: str,
parent=None,
*,
name_required: bool = True,
show_name: bool | None = None,
) -> None:
super().__init__(parent)
self.name_required = name_required
self.setWindowTitle(title)
@@ -21,6 +30,11 @@ class ItemOptionsDialog(QDialog):
self.form = QFormLayout()
self.name_edit = QLineEdit(name, self)
self.form.addRow("Name:", self.name_edit)
self.show_name_check = None
if show_name is not None:
self.show_name_check = QCheckBox("Show name below connection", self)
self.show_name_check.setChecked(show_name)
self.form.addRow("", self.show_name_check)
layout.addLayout(self.form)
buttons = QDialogButtonBox(
@@ -35,6 +49,10 @@ class ItemOptionsDialog(QDialog):
def name(self) -> str:
return self.name_edit.text().strip()
@property
def show_name(self) -> bool:
return bool(self.show_name_check and self.show_name_check.isChecked())
def accept(self) -> None:
if self.name_required and not self.name:
QMessageBox.warning(self, "Invalid name", "The name cannot be empty.")

View File

@@ -0,0 +1,159 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QDialog, QListWidgetItem, QMessageBox
from bedit.core.model import Component, Parameter
from bedit.core.physical_types import QUANTITIES, UNITS
from bedit.gui.generated.ui_parameter_options_dialog import Ui_ParameterOptionsDialog
PARAMETER_ROLE = Qt.ItemDataRole.UserRole
class ParameterOptionsDialog(QDialog):
"""Editor for parameters shared by graph and text components."""
edited = Signal()
def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None:
super().__init__(parent)
self.ui = Ui_ParameterOptionsDialog()
self.ui.setupUi(self)
self.setWindowTitle(f"Parameter Options — {component.name}")
self.parameters = deepcopy(component.parameters)
self.read_only = read_only
self._loading = False
self.ui.quantityCombo.addItems(QUANTITIES)
self.ui.unitCombo.addItems(UNITS)
self.ui.parameterList.currentRowChanged.connect(self._load_current)
self.ui.addParameterButton.clicked.connect(self.add_parameter)
self.ui.removeParameterButton.clicked.connect(self.remove_parameter)
self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentTextChanged.connect(self._store_current)
self.ui.valueEdit.textEdited.connect(self._store_current)
self.ui.quantityCombo.currentTextChanged.connect(self._store_current)
self.ui.unitCombo.currentTextChanged.connect(self._store_current)
self.ui.rowsSpin.valueChanged.connect(self._store_current)
self.ui.columnsSpin.valueChanged.connect(self._store_current)
self.ui.descriptionEdit.textChanged.connect(self._store_current)
self.ui.parameterSplitter.setSizes([250, 370])
if read_only:
self.ui.addParameterButton.setEnabled(False)
self.ui.removeParameterButton.setEnabled(False)
self.ui.nameEdit.setReadOnly(True)
self.ui.typeCombo.setEnabled(False)
self.ui.valueEdit.setReadOnly(True)
self.ui.quantityCombo.setEnabled(False)
self.ui.unitCombo.setEnabled(False)
self.ui.rowsSpin.setEnabled(False)
self.ui.columnsSpin.setEnabled(False)
self.ui.descriptionEdit.setReadOnly(True)
self._rebuild_list(0 if self.parameters else -1)
def _rebuild_list(self, row: int = -1) -> None:
self.ui.parameterList.clear()
for parameter in self.parameters:
item = QListWidgetItem(f"{parameter.name} [{parameter.type}] = {parameter.value}")
item.setData(PARAMETER_ROLE, parameter.id)
self.ui.parameterList.addItem(item)
self.ui.parameterList.setCurrentRow(min(row, len(self.parameters) - 1))
self._update_enabled()
def _load_current(self, row: int) -> None:
self._loading = True
if 0 <= row < len(self.parameters):
parameter = self.parameters[row]
self.ui.nameEdit.setText(parameter.name)
self.ui.typeCombo.setCurrentText(parameter.type)
self.ui.valueEdit.setText(parameter.value)
self.ui.quantityCombo.setCurrentText(parameter.quantity)
self.ui.unitCombo.setCurrentText(parameter.unit)
self.ui.rowsSpin.setValue(parameter.rows)
self.ui.columnsSpin.setValue(parameter.columns)
self.ui.descriptionEdit.setPlainText(parameter.description)
else:
self.ui.nameEdit.clear()
self.ui.typeCombo.setCurrentText("")
self.ui.valueEdit.clear()
self.ui.quantityCombo.setCurrentText("")
self.ui.unitCombo.setCurrentText("")
self.ui.descriptionEdit.clear()
self._loading = False
self._update_enabled()
def _update_enabled(self) -> None:
enabled = self.ui.parameterList.currentRow() >= 0
self.ui.removeParameterButton.setEnabled(enabled and not self.read_only)
self.ui.nameEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled and not self.read_only)
self.ui.valueEdit.setEnabled(enabled)
self.ui.quantityCombo.setEnabled(enabled and not self.read_only)
self.ui.unitCombo.setEnabled(enabled and not self.read_only)
self.ui.rowsSpin.setEnabled(enabled and not self.read_only)
self.ui.columnsSpin.setEnabled(enabled and not self.read_only)
self.ui.descriptionEdit.setEnabled(enabled)
def _store_current(self) -> None:
row = self.ui.parameterList.currentRow()
if self._loading or not (0 <= row < len(self.parameters)):
return
parameter = self.parameters[row]
parameter.name = self.ui.nameEdit.text()
parameter.type = self.ui.typeCombo.currentText().strip() or "real"
parameter.value = self.ui.valueEdit.text()
parameter.quantity = self.ui.quantityCombo.currentText().strip()
parameter.unit = self.ui.unitCombo.currentText().strip()
parameter.rows = self.ui.rowsSpin.value()
parameter.columns = self.ui.columnsSpin.value()
parameter.description = self.ui.descriptionEdit.toPlainText()
self.ui.parameterList.item(row).setText(
f"{parameter.name} [{parameter.type}] = {parameter.value}"
)
self.edited.emit()
def set_parameters(self, parameters: list[Parameter]) -> None:
self._loading = True
self.parameters = deepcopy(parameters)
self._loading = False
self._rebuild_list(0 if self.parameters else -1)
def add_parameter(self) -> None:
self.parameters.append(
Parameter(
id=f"parameter-{uuid4().hex[:8]}",
name=f"Parameter {len(self.parameters) + 1}",
)
)
self._rebuild_list(len(self.parameters) - 1)
self.ui.nameEdit.selectAll()
self.ui.nameEdit.setFocus()
self.edited.emit()
def remove_parameter(self) -> None:
row = self.ui.parameterList.currentRow()
if row >= 0:
self.parameters.pop(row)
self._rebuild_list(min(row, len(self.parameters) - 1))
self.edited.emit()
def accept(self) -> None:
self._store_current()
if any(not parameter.name.strip() for parameter in self.parameters):
QMessageBox.warning(self, "Invalid parameter", "Every parameter needs a name.")
return
names = [parameter.name for parameter in self.parameters]
if len(set(names)) != len(names):
QMessageBox.warning(self, "Invalid parameter", "Parameter names must be unique.")
return
super().accept()
class ParameterEditor(ParameterOptionsDialog):
"""The parameter options editor embedded without dialog buttons."""
def __init__(self, parent=None) -> None:
super().__init__(Component(id="embedded-parameter-editor", name="Parameters"), parent)
self.setWindowFlags(Qt.WindowType.Widget)
self.ui.buttonBox.hide()

View File

@@ -0,0 +1,239 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QListWidgetItem, QMessageBox
from bedit.core.model import Component, Port
from bedit.core.physical_types import QUANTITIES, UNITS
from bedit.core.power_domains import POWER_DOMAINS, power_causalities, power_domain
from bedit.core.port_types import PortTypeRegistry
from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog
PORT_ROLE = Qt.ItemDataRole.UserRole
class PortOptionsDialog(QDialog):
"""Unified editor for a component's typed, oriented ports."""
edited = Signal()
def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None:
super().__init__(parent)
self.ui = Ui_PortOptionsDialog()
self.ui.setupUi(self)
self.setWindowTitle(f"Port Options — {component.name}")
self.read_only = read_only
self.ports = deepcopy(component.ports)
self._loading = False
self.ui.typeCombo.clear()
for port_type in PortTypeRegistry.all():
self.ui.typeCombo.addItem(port_type.display_name, port_type.id)
self.ui.quantityCombo.addItems(QUANTITIES)
self.ui.unitCombo.addItems(UNITS)
self.ui.orientationCombo.setItemData(0, "input")
self.ui.orientationCombo.setItemData(1, "output")
self.ui.orientationCombo.setItemData(2, "indifferent")
for domain in POWER_DOMAINS:
self.ui.domainCombo.addItem(domain.display_name, domain.id)
self._refresh_causality_options()
self.ui.portList.currentRowChanged.connect(self._load_current)
self.ui.addPortButton.clicked.connect(self.add_port)
self.ui.removePortButton.clicked.connect(self.remove_port)
self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._port_type_changed)
self.ui.orientationCombo.currentIndexChanged.connect(self._store_current)
self.ui.multipleConnectionsCheckBox.toggled.connect(
self._multiple_connections_changed
)
self.ui.valueTypeCombo.currentTextChanged.connect(self._store_current)
self.ui.quantityCombo.currentTextChanged.connect(self._store_current)
self.ui.unitCombo.currentTextChanged.connect(self._store_current)
self.ui.rowsSpin.valueChanged.connect(self._store_current)
self.ui.columnsSpin.valueChanged.connect(self._store_current)
self.ui.descriptionEdit.textChanged.connect(self._store_current)
self.ui.domainCombo.currentIndexChanged.connect(self._power_domain_changed)
self.ui.causalityCombo.currentTextChanged.connect(self._store_current)
self.ui.powerDescriptionEdit.textChanged.connect(self._store_current)
self.ui.portSplitter.setSizes([250, 370])
if read_only:
self.ui.addPortButton.setEnabled(False)
self.ui.removePortButton.setEnabled(False)
self.ui.nameEdit.setReadOnly(True)
self.ui.typeCombo.setEnabled(False)
self.ui.orientationCombo.setEnabled(False)
self.ui.multipleConnectionsCheckBox.setEnabled(False)
self.ui.typeOptionsStack.setEnabled(False)
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText("Close")
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide()
self._rebuild_list(0 if self.ports else -1)
def _rebuild_list(self, row: int = -1) -> None:
self.ui.portList.clear()
for port in self.ports:
item = QListWidgetItem(f"{port.name} [{port.orientation}, {port.type}]")
item.setData(PORT_ROLE, port.id)
self.ui.portList.addItem(item)
self.ui.portList.setCurrentRow(min(row, len(self.ports) - 1))
self._update_enabled()
def _load_current(self, row: int) -> None:
self._loading = True
enabled = 0 <= row < len(self.ports)
if enabled:
port = self.ports[row]
self.ui.nameEdit.setText(port.name)
self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type))
self.ui.orientationCombo.setCurrentIndex(
self.ui.orientationCombo.findData(port.orientation)
)
self.ui.multipleConnectionsCheckBox.setChecked(
port.allows_multiple_connections
)
self.ui.valueTypeCombo.setCurrentText(port.value_type)
self.ui.quantityCombo.setCurrentText(port.quantity)
self.ui.unitCombo.setCurrentText(port.unit)
self.ui.rowsSpin.setValue(port.rows)
self.ui.columnsSpin.setValue(port.columns)
self.ui.descriptionEdit.setPlainText(port.description)
self.ui.domainCombo.setCurrentIndex(self.ui.domainCombo.findData(port.domain))
self._refresh_causality_options(
port.causality,
port_type=port.type,
allows_multiple_connections=port.allows_multiple_connections,
)
self.ui.causalityCombo.setCurrentText(port.causality)
self.ui.powerDescriptionEdit.setPlainText(port.description)
self._show_type_options(port.type)
else:
self.ui.nameEdit.clear()
self.ui.descriptionEdit.clear()
self._loading = False
self._update_enabled()
def _update_enabled(self) -> None:
enabled = self.ui.portList.currentRow() >= 0
self.ui.removePortButton.setEnabled(enabled and not self.read_only)
self.ui.nameEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled and not self.read_only)
self.ui.orientationCombo.setEnabled(enabled and not self.read_only)
self.ui.multipleConnectionsCheckBox.setEnabled(enabled and not self.read_only)
self.ui.typeOptionsStack.setEnabled(enabled and not self.read_only)
def _port_type_changed(self) -> None:
port_type = self.ui.typeCombo.currentData()
indifferent_item = self.ui.orientationCombo.model().item(2)
indifferent_item.setEnabled(port_type == "power")
if port_type != "power" and self.ui.orientationCombo.currentData() == "indifferent":
self.ui.orientationCombo.setCurrentIndex(0)
self._refresh_causality_options(port_type=port_type)
self._show_type_options(port_type)
self._store_current()
def _multiple_connections_changed(self) -> None:
self._refresh_causality_options()
self._store_current()
def _refresh_causality_options(
self,
selected: str | None = None,
*,
port_type: str | None = None,
allows_multiple_connections: bool | None = None,
) -> None:
selected = selected or self.ui.causalityCombo.currentText() or "indifferent"
port_type = port_type or self.ui.typeCombo.currentData()
if allows_multiple_connections is None:
allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
choices = power_causalities(
port_type == "power" and allows_multiple_connections
)
self.ui.causalityCombo.blockSignals(True)
self.ui.causalityCombo.clear()
self.ui.causalityCombo.addItems(choices)
self.ui.causalityCombo.setCurrentText(
selected if selected in choices else "indifferent"
)
self.ui.causalityCombo.blockSignals(False)
def _show_type_options(self, port_type: str) -> None:
self.ui.orientationCombo.model().item(2).setEnabled(port_type == "power")
page = {
"signal": self.ui.signalOptionsPage,
"power": self.ui.powerOptionsPage,
}.get(port_type, self.ui.unsupportedTypePage)
self.ui.typeOptionsStack.setCurrentWidget(page)
def _power_domain_changed(self) -> None:
domain = power_domain(self.ui.domainCombo.currentData())
self.ui.effortValueLabel.setText(domain.effort)
self.ui.flowValueLabel.setText(domain.flow)
self._store_current()
def _store_current(self) -> None:
row = self.ui.portList.currentRow()
if self._loading or not (0 <= row < len(self.ports)):
return
port = self.ports[row]
port.name = self.ui.nameEdit.text()
port.type = self.ui.typeCombo.currentData()
port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
port.value_type = self.ui.valueTypeCombo.currentText().strip() or "real"
port.quantity = self.ui.quantityCombo.currentText().strip()
port.unit = self.ui.unitCombo.currentText().strip()
port.rows = self.ui.rowsSpin.value()
port.columns = self.ui.columnsSpin.value()
port.domain = self.ui.domainCombo.currentData() or "power"
port.causality = self.ui.causalityCombo.currentText()
port.description = (
self.ui.powerDescriptionEdit.toPlainText()
if port.type == "power"
else self.ui.descriptionEdit.toPlainText()
)
orientation = self.ui.orientationCombo.currentData()
port.orientation = orientation
self.ui.portList.item(row).setText(f"{port.name} [{port.orientation}, {port.type}]")
self.edited.emit()
def set_ports(self, ports: list[Port]) -> None:
self._loading = True
self.ports = deepcopy(ports)
self._loading = False
self._rebuild_list(0 if self.ports else -1)
def add_port(self) -> None:
port = Port(
id=f"port-{uuid4().hex[:8]}",
name=f"Port {len(self.ports) + 1}",
properties={"iconPosition": {"x": 0.0, "y": 0.0}},
type="signal",
)
self.ports.append(port)
self._rebuild_list(len(self.ports) - 1)
self.ui.nameEdit.selectAll()
self.ui.nameEdit.setFocus()
self.edited.emit()
def remove_port(self) -> None:
row = self.ui.portList.currentRow()
if row >= 0:
self.ports.pop(row)
self._rebuild_list(min(row, len(self.ports) - 1))
self.edited.emit()
def accept(self) -> None:
self._store_current()
if any(not port.name.strip() for port in self.ports):
QMessageBox.warning(self, "Invalid port", "Every port must have a name.")
return
super().accept()
class PortEditor(PortOptionsDialog):
"""The port options editor embedded without dialog confirmation buttons."""
def __init__(self, parent=None) -> None:
super().__init__(Component(id="embedded-port-editor", name="Ports"), parent)
self.setWindowFlags(Qt.WindowType.Widget)
self.ui.buttonBox.hide()

View File

@@ -0,0 +1,240 @@
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QColorDialog, QDialog, QFileDialog, QHeaderView, QTableWidgetItem
from bedit.core.libraries import bundled_library_path, default_library_paths
from bedit.gui.preferences import application_settings
from bedit.gui.generated.ui_settings_dialog import Ui_SettingsDialog
from bedit.gui.editors.openmodelica import HIGHLIGHT_STYLES
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.ui.graphGridSpinBox.valueChanged.connect(
self.ui.graphSnapSpinBox.setMaximum
)
self.settings = application_settings()
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.browseOpenModelicaButton.clicked.connect(self._browse_openmodelica)
self.ui.libraryPathsList.itemSelectionChanged.connect(self._update_remove_button)
self.ui.syntaxStylesTable.cellDoubleClicked.connect(self._choose_syntax_color)
self._load_settings()
def _load_settings(self) -> None:
enabled = self.settings.value("autosave/enabled", None)
if enabled is None:
enabled = self.settings.value("General/autosaveEnabled", False)
interval = self.settings.value("autosave/intervalMinutes", None)
if interval is None:
interval = self.settings.value("General/autosaveInterval", 5)
self.ui.autosaveGroupBox.setChecked(
self._as_bool(enabled)
)
self.ui.autosaveIntervalSpinBox.setValue(int(interval))
self.ui.libraryPathsList.clear()
self.ui.libraryPathsList.addItems(self.library_paths(self.settings))
self.ui.graphGridSpinBox.setValue(self.graph_grid_size(self.settings))
self.ui.graphSnapSpinBox.setValue(self.graph_snap_size(self.settings))
self.ui.iconGridSpinBox.setValue(self.icon_grid_size(self.settings))
self.ui.automaticCausalityCheckBox.setChecked(
self.infer_causality_on_connection_change(self.settings)
)
self.ui.openModelicaPathEdit.setText(self.openmodelica_path(self.settings))
self._load_syntax_styles()
self._update_remove_button()
def _load_syntax_styles(self) -> None:
table = self.ui.syntaxStylesTable
table.setRowCount(len(HIGHLIGHT_STYLES))
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
for row, (category, defaults) in enumerate(HIGHLIGHT_STYLES.items()):
label, default_color, default_bold, default_italic = defaults
name_item = QTableWidgetItem(label)
name_item.setData(Qt.ItemDataRole.UserRole, category)
name_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
table.setItem(row, 0, name_item)
color = str(self.settings.value(f"syntax/{category}/color", default_color))
color_item = QTableWidgetItem(color)
color_item.setBackground(QColor(color))
color_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
table.setItem(row, 1, color_item)
for column, key, default in (
(2, "bold", default_bold),
(3, "italic", default_italic),
):
item = QTableWidgetItem()
item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
enabled = self._as_bool(
self.settings.value(f"syntax/{category}/{key}", default)
)
item.setCheckState(
Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked
)
table.setItem(row, column, item)
def _choose_syntax_color(self, row: int, column: int) -> None:
if column != 1:
return
item = self.ui.syntaxStylesTable.item(row, column)
color = QColorDialog.getColor(QColor(item.text()), self, "Highlight colour")
if color.isValid():
item.setText(color.name())
item.setBackground(color)
@staticmethod
def _as_bool(value) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@staticmethod
def library_paths(settings: QSettings | None = None) -> list[str]:
settings = settings if settings is not None else application_settings()
value = settings.value("libraries/paths", default_library_paths())
if isinstance(value, str):
paths = [value]
else:
paths = [str(path) for path in value]
bundled = bundled_library_path()
migrated = [
str(bundled)
if not Path(path).exists()
and Path(path).parent == bundled.parent
and Path(path).suffix.lower() == ".json"
else path
for path in paths
]
if migrated != paths:
settings.setValue("libraries/paths", migrated)
settings.sync()
return migrated
@staticmethod
def graph_grid_size(settings: QSettings | None = None) -> int:
settings = settings if settings is not None else application_settings()
return settings.value("grid/graphSize", 64, type=int)
@staticmethod
def graph_snap_size(settings: QSettings | None = None) -> int:
settings = settings if settings is not None else application_settings()
return settings.value("grid/graphSnapSize", 8, type=int)
@staticmethod
def icon_grid_size(settings: QSettings | None = None) -> int:
settings = settings if settings is not None else application_settings()
return settings.value("grid/iconSize", 8, type=int)
@staticmethod
def infer_causality_on_connection_change(
settings: QSettings | None = None,
) -> bool:
settings = settings if settings is not None else application_settings()
return settings.value(
"bondGraph/inferCausalityOnConnectionChange", True, type=bool
)
@staticmethod
def openmodelica_path(settings: QSettings | None = None) -> str:
settings = settings if settings is not None else application_settings()
return str(settings.value("simulation/openModelicaPath", "") or "")
def _browse_openmodelica(self) -> None:
current = self.ui.openModelicaPathEdit.text().strip()
start = str(Path(current).expanduser().parent) if current else ""
path, _ = QFileDialog.getOpenFileName(
self,
"Select OpenModelica executable",
start,
"OpenModelica compiler (omc omc.exe);;All files (*)",
)
if path:
# Keep symlink paths such as ~/.local/bin/omc intact; resolving them
# could turn the selected launcher into an unrelated container script.
self.ui.openModelicaPathEdit.setText(str(Path(path).expanduser().absolute()))
def _add_library_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Add library",
"",
"BEdit libraries (*.beb *.bedit.json *.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("autosave/enabled", self.ui.autosaveGroupBox.isChecked())
self.settings.setValue(
"autosave/intervalMinutes", self.ui.autosaveIntervalSpinBox.value()
)
self.settings.remove("General/autosaveEnabled")
self.settings.remove("General/autosaveInterval")
paths = [
self.ui.libraryPathsList.item(row).text()
for row in range(self.ui.libraryPathsList.count())
]
self.settings.setValue("libraries/paths", paths)
self.settings.setValue("grid/graphSize", self.ui.graphGridSpinBox.value())
self.settings.setValue("grid/graphSnapSize", self.ui.graphSnapSpinBox.value())
self.settings.setValue("grid/iconSize", self.ui.iconGridSpinBox.value())
self.settings.setValue(
"bondGraph/inferCausalityOnConnectionChange",
self.ui.automaticCausalityCheckBox.isChecked(),
)
self.settings.setValue(
"simulation/openModelicaPath",
self.ui.openModelicaPathEdit.text().strip(),
)
for row in range(self.ui.syntaxStylesTable.rowCount()):
category = self.ui.syntaxStylesTable.item(row, 0).data(
Qt.ItemDataRole.UserRole
)
self.settings.setValue(
f"syntax/{category}/color",
self.ui.syntaxStylesTable.item(row, 1).text(),
)
self.settings.setValue(
f"syntax/{category}/bold",
self.ui.syntaxStylesTable.item(row, 2).checkState()
== Qt.CheckState.Checked,
)
self.settings.setValue(
f"syntax/{category}/italic",
self.ui.syntaxStylesTable.item(row, 3).checkState()
== Qt.CheckState.Checked,
)
self.settings.sync()
self.settingsChanged.emit()
super().accept()

View File

@@ -0,0 +1,60 @@
from copy import deepcopy
from PySide6.QtWidgets import QButtonGroup, QDialog
from bedit.gui.generated.ui_simulation_settings_dialog import Ui_SimulationSettingsDialog
class SimulationSettingsDialog(QDialog):
def __init__(self, settings: dict, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_SimulationSettingsDialog()
self.ui.setupUi(self)
self._settings = deepcopy(settings)
self.interval_mode_group = QButtonGroup(self)
self.interval_mode_group.setExclusive(True)
self.interval_mode_group.addButton(self.ui.numberOfIntervalsRadioButton)
self.interval_mode_group.addButton(self.ui.intervalTimeRadioButton)
self.ui.startTimeSpinBox.setValue(float(settings.get("startTime", 0.0)))
self.ui.stopTimeSpinBox.setValue(float(settings.get("stopTime", 1.0)))
self.ui.numberOfIntervalsSpinBox.setValue(
int(settings.get("numberOfIntervals", 500))
)
self.ui.intervalTimeSpinBox.setValue(float(settings.get("intervalTime", 0.002)))
interval_mode = settings.get("intervalMode", "numberOfIntervals")
if interval_mode == "intervalTime":
self.ui.intervalTimeRadioButton.setChecked(True)
else:
self.ui.numberOfIntervalsRadioButton.setChecked(True)
self.ui.numberOfIntervalsRadioButton.toggled.connect(
self._update_interval_fields
)
self.ui.intervalTimeRadioButton.toggled.connect(self._update_interval_fields)
self._update_interval_fields()
def _update_interval_fields(self) -> None:
use_number = self.ui.numberOfIntervalsRadioButton.isChecked()
self.ui.numberOfIntervalsSpinBox.setEnabled(use_number)
self.ui.intervalTimeSpinBox.setEnabled(not use_number)
@property
def settings(self) -> dict:
values = deepcopy(self._settings)
values.update(
{
"startTime": self.ui.startTimeSpinBox.value(),
"stopTime": self.ui.stopTimeSpinBox.value(),
"intervalMode": (
"numberOfIntervals"
if self.ui.numberOfIntervalsRadioButton.isChecked()
else "intervalTime"
),
"numberOfIntervals": self.ui.numberOfIntervalsSpinBox.value(),
"intervalTime": self.ui.intervalTimeSpinBox.value(),
}
)
return values

View File

@@ -0,0 +1 @@
"""Reusable editing widgets."""

View File

@@ -0,0 +1,252 @@
import json
from importlib.resources import files
from PySide6.QtCore import QRegularExpression, QStringListModel, Qt
from PySide6.QtGui import (
QColor,
QFont,
QKeyEvent,
QSyntaxHighlighter,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import QCompleter, QPlainTextEdit
from bedit.gui.preferences import application_settings
HIGHLIGHT_STYLES = {
"keywords": ("Keywords", "#7c3aed", True, False),
"types": ("Types", "#0369a1", True, False),
"builtins": ("Built-ins", "#0f766e", False, False),
"bevalues": ("BEvalues", "#c026d3", True, False),
"inputs": ("Inputs", "#1d4ed8", False, False),
"outputs": ("Outputs", "#be123c", False, False),
"parameters": ("Parameters", "#a16207", False, False),
"numbers": ("Numbers", "#b45309", False, False),
"strings": ("Strings", "#15803d", False, False),
"comments": ("Comments", "#6b7280", False, True),
}
def load_openmodelica_syntax() -> dict[str, list[str]]:
resource = files("bedit").joinpath("data/syntax/openmodelica.json")
with resource.open(encoding="utf-8") as file:
values = json.load(file)
return {
category: [str(word) for word in values.get(category, [])]
for category in ("keywords", "types", "builtins", "bevalues")
}
def _format(color: str, *, bold: bool = False, italic: bool = False) -> QTextCharFormat:
value = QTextCharFormat()
value.setForeground(QColor(color))
value.setFontWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal)
value.setFontItalic(italic)
return value
def _as_bool(value) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def highlighting_style(category: str) -> tuple[str, bool, bool]:
_label, default_color, default_bold, default_italic = HIGHLIGHT_STYLES[category]
settings = application_settings()
prefix = f"syntax/{category}"
return (
str(settings.value(f"{prefix}/color", default_color)),
_as_bool(settings.value(f"{prefix}/bold", default_bold)),
_as_bool(settings.value(f"{prefix}/italic", default_italic)),
)
class OpenModelicaHighlighter(QSyntaxHighlighter):
"""Syntax highlighter driven by the editable OpenModelica word list."""
def __init__(
self,
document,
syntax: dict[str, list[str]],
symbols: dict[str, list[str]],
) -> None:
super().__init__(document)
self.rules: list[tuple[QRegularExpression, QTextCharFormat]] = []
for category in (
"keywords",
"types",
"builtins",
"inputs",
"outputs",
"parameters",
):
words = syntax.get(category, symbols.get(category, []))
if words:
pattern = r"\b(?:" + "|".join(map(QRegularExpression.escape, words)) + r")\b"
color, bold, italic = highlighting_style(category)
self.rules.append(
(QRegularExpression(pattern), _format(color, bold=bold, italic=italic))
)
bevalue_style = highlighting_style("bevalues")
number_style = highlighting_style("numbers")
string_style = highlighting_style("strings")
self.rules.extend(
[
(
QRegularExpression(r"\$[A-Za-z_][A-Za-z0-9_]*\$"),
_format(
bevalue_style[0],
bold=bevalue_style[1],
italic=bevalue_style[2],
),
),
(
QRegularExpression(r"\b(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b"),
_format(number_style[0], bold=number_style[1], italic=number_style[2]),
),
(
QRegularExpression(r'"(?:\\.|[^"\\])*"'),
_format(string_style[0], bold=string_style[1], italic=string_style[2]),
),
]
)
comment_style = highlighting_style("comments")
self.comment_format = _format(
comment_style[0], bold=comment_style[1], italic=comment_style[2]
)
self.rules.append((QRegularExpression(r"//.*$"), self.comment_format))
self.comment_start = QRegularExpression(r"/\*")
self.comment_end = QRegularExpression(r"\*/")
def highlightBlock(self, text: str) -> None: # noqa: N802
for expression, text_format in self.rules:
match = expression.globalMatch(text)
while match.hasNext():
result = match.next()
self.setFormat(result.capturedStart(), result.capturedLength(), text_format)
self.setCurrentBlockState(0)
start = (
0
if self.previousBlockState() == 1
else self.comment_start.match(text).capturedStart()
)
while start >= 0:
end_match = self.comment_end.match(text, start + 2)
if end_match.hasMatch():
length = end_match.capturedEnd() - start
else:
self.setCurrentBlockState(1)
length = len(text) - start
self.setFormat(start, length, self.comment_format)
if not end_match.hasMatch():
break
start = self.comment_start.match(text, start + length).capturedStart()
class OpenModelicaEditor(QPlainTextEdit):
"""OpenModelica text editor with syntax highlighting and completion."""
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.setPlaceholderText("Enter OpenModelica equations here…")
self.syntax = load_openmodelica_syntax()
self.symbols = {"inputs": [], "outputs": [], "parameters": []}
self.highlighter = OpenModelicaHighlighter(
self.document(), self.syntax, self.symbols
)
self.completer = QCompleter(self)
self.completer.setWidget(self)
self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.completer.activated.connect(self._insert_completion)
self._rebuild_completions()
def set_symbols(
self,
inputs: list[str],
outputs: list[str],
parameters: list[str],
) -> None:
self.symbols = {
"inputs": [name for name in inputs if name],
"outputs": [name for name in outputs if name],
"parameters": [name for name in parameters if name],
}
self.reload_highlighting()
def reload_highlighting(self) -> None:
self.highlighter.setDocument(None)
self.highlighter = OpenModelicaHighlighter(
self.document(), self.syntax, self.symbols
)
self._rebuild_completions()
def _rebuild_completions(self) -> None:
words = sorted(
{
*self.syntax["keywords"],
*self.syntax["types"],
*self.syntax["builtins"],
*(f"${name.strip('$')}$" for name in self.syntax["bevalues"]),
*self.symbols["inputs"],
*self.symbols["outputs"],
*self.symbols["parameters"],
},
key=str.casefold,
)
self.completer.setModel(QStringListModel(words, self.completer))
def _completion_prefix(self) -> str:
cursor = self.textCursor()
text = cursor.block().text()[: cursor.positionInBlock()]
index = len(text)
while index > 0 and (text[index - 1].isalnum() or text[index - 1] in "_$"):
index -= 1
return text[index:]
def _insert_completion(self, completion: str) -> None:
prefix = self._completion_prefix()
cursor = self.textCursor()
cursor.movePosition(
QTextCursor.MoveOperation.Left,
QTextCursor.MoveMode.KeepAnchor,
len(prefix),
)
cursor.insertText(completion)
self.setTextCursor(cursor)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
popup = self.completer.popup()
if popup.isVisible() and event.key() in {
Qt.Key.Key_Enter,
Qt.Key.Key_Return,
Qt.Key.Key_Escape,
Qt.Key.Key_Tab,
Qt.Key.Key_Backtab,
}:
event.ignore()
return
explicit = (
event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Space
)
if not explicit:
super().keyPressEvent(event)
prefix = self._completion_prefix()
if not explicit and (len(prefix) < 2 or event.text() == ""):
popup.hide()
return
self.completer.setCompletionPrefix(prefix)
if self.completer.completionCount() == 0:
popup.hide()
return
rectangle = self.cursorRect()
rectangle.setWidth(
popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width()
)
self.completer.complete(rectangle)

View File

@@ -0,0 +1,110 @@
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from bedit.core.model import Parameter, Port
from bedit.gui.generated.ui_text_definition_editor import Ui_TextDefinitionEditor
class TextDefinitionEditor(QWidget):
"""Editor for a text component's Modelica source, ports, and parameters."""
modifiedChanged = Signal(bool)
definitionEdited = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_TextDefinitionEditor()
self.ui.setupUi(self)
self._modified = False
self._loading = False
self.ui.columnSplitter.setSizes([560, 340])
self.ui.sourceSplitter.setSizes([180, 180, 240])
self.ui.definitionSplitter.setSizes([300, 300])
self.ui.declarationsEdit.textChanged.connect(self._mark_modified)
self.ui.initialEquationsEdit.textChanged.connect(self._mark_modified)
self.ui.equationsEdit.textChanged.connect(self._mark_modified)
self.ui.portEditor.edited.connect(self._symbols_modified)
self.ui.parameterEditor.edited.connect(self._symbols_modified)
@property
def is_modified(self) -> bool:
return self._modified
@property
def equations(self) -> str:
return self.ui.equationsEdit.toPlainText()
@property
def declarations(self) -> str:
return self.ui.declarationsEdit.toPlainText()
@property
def initial_equations(self) -> str:
return self.ui.initialEquationsEdit.toPlainText()
@property
def ports(self) -> list[Port]:
return self.ui.portEditor.ports
@property
def parameters(self) -> list[Parameter]:
return self.ui.parameterEditor.parameters
def set_definition(
self,
declarations: str,
initial_equations: str,
equations: str,
ports: list[Port],
parameters: list[Parameter],
) -> None:
self._loading = True
self.ui.declarationsEdit.setPlainText(declarations)
self.ui.initialEquationsEdit.setPlainText(initial_equations)
self.ui.equationsEdit.setPlainText(equations)
self.ui.portEditor.set_ports(ports)
self.ui.parameterEditor.set_parameters(parameters)
self._set_editor_symbols(ports, parameters)
self._loading = False
self.set_modified(False)
def set_modified(self, modified: bool) -> None:
if self._modified != modified:
self._modified = modified
self.modifiedChanged.emit(modified)
def _mark_modified(self, *_args) -> None:
if not self._loading:
self.set_modified(True)
self.definitionEdited.emit()
def _symbols_modified(self, *_args) -> None:
if not self._loading:
self._refresh_editor_symbols()
self._mark_modified()
def _refresh_editor_symbols(self) -> None:
self._set_editor_symbols(self.ports, self.parameters)
def _set_editor_symbols(
self,
ports: list[Port],
parameters: list[Parameter],
) -> None:
self._apply_editor_symbols(
[
port.name
for port in ports
if port.orientation in {"input", "indifferent"}
],
[port.name for port in ports if port.orientation == "output"],
[parameter.name for parameter in parameters],
)
def _apply_editor_symbols(
self, inputs: list[str], outputs: list[str], parameters: list[str]
) -> None:
symbols = (inputs, outputs, parameters)
self.ui.declarationsEdit.set_symbols(*symbols)
self.ui.initialEquationsEdit.set_symbols(*symbols)
self.ui.equationsEdit.set_symbols(*symbols)

View File

@@ -0,0 +1 @@
"""Generated Qt code. Do not edit these modules by hand."""

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,9 @@ 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,
from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QDialog,
QDialogButtonBox, QFormLayout, QLabel, QLineEdit,
QPushButton, QSizePolicy, QSpacerItem, QVBoxLayout,
QWidget)
class Ui_ComponentOptionsDialog(object):
@@ -39,53 +39,26 @@ class Ui_ComponentOptionsDialog(object):
self.optionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.shapeLabel = QLabel(ComponentOptionsDialog)
self.shapeLabel.setObjectName(u"shapeLabel")
self.iconLabel = QLabel(ComponentOptionsDialog)
self.iconLabel.setObjectName(u"iconLabel")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.shapeLabel)
self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.iconLabel)
self.shapeCombo = QComboBox(ComponentOptionsDialog)
self.shapeCombo.addItem("")
self.shapeCombo.addItem("")
self.shapeCombo.setObjectName(u"shapeCombo")
self.editIconButton = QPushButton(ComponentOptionsDialog)
self.editIconButton.setObjectName(u"editIconButton")
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.optionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.editIconButton)
self.showSubtreeCheckBox = QCheckBox(ComponentOptionsDialog)
self.showSubtreeCheckBox.setObjectName(u"showSubtreeCheckBox")
self.showSubtreeCheckBox.setChecked(True)
self.optionsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox)
self.optionsForm.setWidget(2, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox)
self.showNameCheckBox = QCheckBox(ComponentOptionsDialog)
self.showNameCheckBox.setObjectName(u"showNameCheckBox")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.showNameCheckBox)
self.dialogLayout.addLayout(self.optionsForm)
@@ -111,15 +84,12 @@ class Ui_ComponentOptionsDialog(object):
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.iconLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon:", None))
self.editIconButton.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Edit Icon\u2026", None))
#if QT_CONFIG(tooltip)
self.editIconButton.setToolTip(QCoreApplication.translate("ComponentOptionsDialog", u"Open the vector icon and port-position editor", None))
#endif // QT_CONFIG(tooltip)
self.showSubtreeCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show contained components in the Libraries tree", None))
self.showNameCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show name below component", None))
# retranslateUi

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'connection_chooser_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,
QLabel, QListWidget, QListWidgetItem, QSizePolicy,
QVBoxLayout, QWidget)
class Ui_ConnectionChooserDialog(object):
def setupUi(self, ConnectionChooserDialog):
if not ConnectionChooserDialog.objectName():
ConnectionChooserDialog.setObjectName(u"ConnectionChooserDialog")
ConnectionChooserDialog.resize(460, 280)
self.dialogLayout = QVBoxLayout(ConnectionChooserDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.promptLabel = QLabel(ConnectionChooserDialog)
self.promptLabel.setObjectName(u"promptLabel")
self.dialogLayout.addWidget(self.promptLabel)
self.connectionList = QListWidget(ConnectionChooserDialog)
self.connectionList.setObjectName(u"connectionList")
self.connectionList.setAlternatingRowColors(True)
self.dialogLayout.addWidget(self.connectionList)
self.buttonBox = QDialogButtonBox(ConnectionChooserDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(ConnectionChooserDialog)
self.buttonBox.accepted.connect(ConnectionChooserDialog.accept)
self.buttonBox.rejected.connect(ConnectionChooserDialog.reject)
self.connectionList.itemDoubleClicked.connect(ConnectionChooserDialog.accept)
QMetaObject.connectSlotsByName(ConnectionChooserDialog)
# setupUi
def retranslateUi(self, ConnectionChooserDialog):
ConnectionChooserDialog.setWindowTitle(QCoreApplication.translate("ConnectionChooserDialog", u"Select a Connection", None))
self.promptLabel.setText(QCoreApplication.translate("ConnectionChooserDialog", u"Select a connection:", None))
# retranslateUi

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'graph_parameters_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,
QHeaderView, QLabel, QSizePolicy, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
class Ui_GraphParametersDialog(object):
def setupUi(self, GraphParametersDialog):
if not GraphParametersDialog.objectName():
GraphParametersDialog.setObjectName(u"GraphParametersDialog")
GraphParametersDialog.resize(620, 480)
self.dialogLayout = QVBoxLayout(GraphParametersDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.descriptionLabel = QLabel(GraphParametersDialog)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.dialogLayout.addWidget(self.descriptionLabel)
self.parameterTree = QTreeWidget(GraphParametersDialog)
self.parameterTree.setObjectName(u"parameterTree")
self.parameterTree.setAlternatingRowColors(True)
self.parameterTree.setRootIsDecorated(True)
self.parameterTree.setColumnCount(3)
self.dialogLayout.addWidget(self.parameterTree)
self.buttonBox = QDialogButtonBox(GraphParametersDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(GraphParametersDialog)
self.buttonBox.accepted.connect(GraphParametersDialog.accept)
self.buttonBox.rejected.connect(GraphParametersDialog.reject)
QMetaObject.connectSlotsByName(GraphParametersDialog)
# setupUi
def retranslateUi(self, GraphParametersDialog):
GraphParametersDialog.setWindowTitle(QCoreApplication.translate("GraphParametersDialog", u"Graph Parameters", None))
self.descriptionLabel.setText(QCoreApplication.translate("GraphParametersDialog", u"Edit parameter values throughout the active component tree.", None))
___qtreewidgetitem = self.parameterTree.headerItem()
___qtreewidgetitem.setText(2, QCoreApplication.translate("GraphParametersDialog", u"Value", None))
___qtreewidgetitem.setText(1, QCoreApplication.translate("GraphParametersDialog", u"Type", None))
___qtreewidgetitem.setText(0, QCoreApplication.translate("GraphParametersDialog", u"Component / Parameter", None))
# retranslateUi

View File

@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'icon_editor_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,
QGraphicsView, QHBoxLayout, QLabel, QPushButton,
QSizePolicy, QSpacerItem, QToolButton, QVBoxLayout,
QWidget)
from bedit.gui.graphics.icon_canvas import IconCanvasView
from . import resources_rc
class Ui_IconEditorDialog(object):
def setupUi(self, IconEditorDialog):
if not IconEditorDialog.objectName():
IconEditorDialog.setObjectName(u"IconEditorDialog")
IconEditorDialog.resize(850, 600)
self.dialogLayout = QVBoxLayout(IconEditorDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.shapeToolbarLayout = QHBoxLayout()
self.shapeToolbarLayout.setObjectName(u"shapeToolbarLayout")
self.pointerButton = QToolButton(IconEditorDialog)
self.pointerButton.setObjectName(u"pointerButton")
icon = QIcon()
icon.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.pointerButton.setIcon(icon)
self.pointerButton.setCheckable(True)
self.pointerButton.setChecked(True)
self.shapeToolbarLayout.addWidget(self.pointerButton)
self.addShapeLabel = QLabel(IconEditorDialog)
self.addShapeLabel.setObjectName(u"addShapeLabel")
self.shapeToolbarLayout.addWidget(self.addShapeLabel)
self.addRectangleButton = QToolButton(IconEditorDialog)
self.addRectangleButton.setObjectName(u"addRectangleButton")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/draw-rectangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addRectangleButton.setIcon(icon1)
self.addRectangleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addRectangleButton)
self.addCircleButton = QToolButton(IconEditorDialog)
self.addCircleButton.setObjectName(u"addCircleButton")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/draw-circle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addCircleButton.setIcon(icon2)
self.addCircleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addCircleButton)
self.addEllipseButton = QToolButton(IconEditorDialog)
self.addEllipseButton.setObjectName(u"addEllipseButton")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/draw-ellipse.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addEllipseButton.setIcon(icon3)
self.addEllipseButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addEllipseButton)
self.addLineButton = QToolButton(IconEditorDialog)
self.addLineButton.setObjectName(u"addLineButton")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/draw-bezier-curves.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addLineButton.setIcon(icon4)
self.addLineButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addLineButton)
self.addTriangleButton = QToolButton(IconEditorDialog)
self.addTriangleButton.setObjectName(u"addTriangleButton")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/draw-triangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addTriangleButton.setIcon(icon5)
self.addTriangleButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addTriangleButton)
self.addTextButton = QToolButton(IconEditorDialog)
self.addTextButton.setObjectName(u"addTextButton")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/draw-text.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.addTextButton.setIcon(icon6)
self.addTextButton.setCheckable(True)
self.shapeToolbarLayout.addWidget(self.addTextButton)
self.toolbarSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.shapeToolbarLayout.addItem(self.toolbarSpacer)
self.zoomInButton = QToolButton(IconEditorDialog)
self.zoomInButton.setObjectName(u"zoomInButton")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/zoom-in.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.zoomInButton.setIcon(icon7)
self.shapeToolbarLayout.addWidget(self.zoomInButton)
self.zoomOutButton = QToolButton(IconEditorDialog)
self.zoomOutButton.setObjectName(u"zoomOutButton")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/zoom-out.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.zoomOutButton.setIcon(icon8)
self.shapeToolbarLayout.addWidget(self.zoomOutButton)
self.centerButton = QToolButton(IconEditorDialog)
self.centerButton.setObjectName(u"centerButton")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.centerButton.setIcon(icon9)
self.shapeToolbarLayout.addWidget(self.centerButton)
self.deleteSelectedButton = QPushButton(IconEditorDialog)
self.deleteSelectedButton.setObjectName(u"deleteSelectedButton")
self.shapeToolbarLayout.addWidget(self.deleteSelectedButton)
self.dialogLayout.addLayout(self.shapeToolbarLayout)
self.iconView = IconCanvasView(IconEditorDialog)
self.iconView.setObjectName(u"iconView")
self.iconView.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.dialogLayout.addWidget(self.iconView)
self.portHintLabel = QLabel(IconEditorDialog)
self.portHintLabel.setObjectName(u"portHintLabel")
self.portHintLabel.setWordWrap(True)
self.dialogLayout.addWidget(self.portHintLabel)
self.buttonBox = QDialogButtonBox(IconEditorDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(IconEditorDialog)
self.buttonBox.accepted.connect(IconEditorDialog.accept)
self.buttonBox.rejected.connect(IconEditorDialog.reject)
QMetaObject.connectSlotsByName(IconEditorDialog)
# setupUi
def retranslateUi(self, IconEditorDialog):
IconEditorDialog.setWindowTitle(QCoreApplication.translate("IconEditorDialog", u"Icon Editor", None))
self.pointerButton.setText(QCoreApplication.translate("IconEditorDialog", u"Pointer", None))
self.addShapeLabel.setText(QCoreApplication.translate("IconEditorDialog", u"Add:", None))
self.addRectangleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Rectangle", None))
self.addCircleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Circle", None))
self.addEllipseButton.setText(QCoreApplication.translate("IconEditorDialog", u"Ellipse", None))
self.addLineButton.setText(QCoreApplication.translate("IconEditorDialog", u"Line", None))
self.addTriangleButton.setText(QCoreApplication.translate("IconEditorDialog", u"Triangle", None))
self.addTextButton.setText(QCoreApplication.translate("IconEditorDialog", u"Text", None))
#if QT_CONFIG(tooltip)
self.zoomInButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Zoom in", None))
#endif // QT_CONFIG(tooltip)
self.zoomInButton.setText(QCoreApplication.translate("IconEditorDialog", u"+", None))
#if QT_CONFIG(tooltip)
self.zoomOutButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Zoom out", None))
#endif // QT_CONFIG(tooltip)
self.zoomOutButton.setText(QCoreApplication.translate("IconEditorDialog", u"\u2212", None))
#if QT_CONFIG(tooltip)
self.centerButton.setToolTip(QCoreApplication.translate("IconEditorDialog", u"Fit and center the icon canvas", None))
#endif // QT_CONFIG(tooltip)
self.centerButton.setText(QCoreApplication.translate("IconEditorDialog", u"Fit", None))
self.deleteSelectedButton.setText(QCoreApplication.translate("IconEditorDialog", u"Delete selected", None))
self.portHintLabel.setText(QCoreApplication.translate("IconEditorDialog", u"Green points are inputs; red points are outputs. Drag them to place connection anchors.", None))
# retranslateUi

View File

@@ -18,72 +18,120 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout,
QHeaderView, QLabel, QMainWindow, QMenu,
QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy,
QSpacerItem, QSplitter, QStackedWidget, QToolBar,
QMenuBar, QPlainTextEdit, QSizePolicy, QSpacerItem,
QSplitter, QStackedWidget, QTabWidget, QToolBar,
QToolButton, QTreeView, QVBoxLayout, QWidget)
from bedit.workspace.view import GraphWorkspaceView
from bedit.gui.editors.text_definition import TextDefinitionEditor
from bedit.gui.graphics.workspace 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)
MainWindow.resize(1209, 777)
self.actionSimulationSettings = QAction(MainWindow)
self.actionSimulationSettings.setObjectName(u"actionSimulationSettings")
icon = QIcon()
icon.addFile(u":/icons/icons/preferences-system.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSimulationSettings.setIcon(icon)
self.actionGraphParameters = QAction(MainWindow)
self.actionGraphParameters.setObjectName(u"actionGraphParameters")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/view-form-table.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionGraphParameters.setIcon(icon1)
self.actionCompose = QAction(MainWindow)
self.actionCompose.setObjectName(u"actionCompose")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/run-build.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCompose.setIcon(icon2)
self.actionRunSimulation = QAction(MainWindow)
self.actionRunSimulation.setObjectName(u"actionRunSimulation")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/media-playback-start.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRunSimulation.setIcon(icon3)
self.actionSimulationWindow = QAction(MainWindow)
self.actionSimulationWindow.setObjectName(u"actionSimulationWindow")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/office-chart-line.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSimulationWindow.setIcon(icon4)
self.actionExportModel = QAction(MainWindow)
self.actionExportModel.setObjectName(u"actionExportModel")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionExportModel.setIcon(icon5)
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)
icon6 = QIcon()
icon6.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon6)
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)
icon7 = QIcon()
icon7.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRotateClockwise.setIcon(icon7)
self.actionZoomIn = QAction(MainWindow)
self.actionZoomIn.setObjectName(u"actionZoomIn")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/zoom-in.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomIn.setIcon(icon8)
self.actionZoomOut = QAction(MainWindow)
self.actionZoomOut.setObjectName(u"actionZoomOut")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/zoom-out.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomOut.setIcon(icon9)
self.actionCenterView = QAction(MainWindow)
self.actionCenterView.setObjectName(u"actionCenterView")
icon10 = QIcon()
icon10.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCenterView.setIcon(icon10)
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)
icon11 = QIcon()
icon11.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon11)
self.actionReloadLibraries = QAction(MainWindow)
self.actionReloadLibraries.setObjectName(u"actionReloadLibraries")
self.actionReloadSimulation = QAction(MainWindow)
self.actionReloadSimulation.setObjectName(u"actionReloadSimulation")
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)
icon12 = QIcon()
icon12.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon12)
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.actionSaveAs.setIcon(icon5)
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)
icon13 = QIcon()
icon13.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon13)
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)
icon14 = QIcon()
icon14.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon14)
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)
icon15 = QIcon()
icon15.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon15)
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)
icon16 = QIcon()
icon16.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon16)
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)
icon17 = QIcon()
icon17.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon17)
self.actionSelectAll = QAction(MainWindow)
self.actionSelectAll.setObjectName(u"actionSelectAll")
self.actionDelete = QAction(MainWindow)
@@ -123,7 +171,9 @@ class Ui_MainWindow(object):
self.librariesLayout.setContentsMargins(0, 0, 0, 0)
self.treeView = QTreeView(self.dockWidgetContents)
self.treeView.setObjectName(u"treeView")
self.treeView.setStyleSheet(u"QTreeView::item { height: 32px; }")
self.treeView.setAlternatingRowColors(True)
self.treeView.setIconSize(QSize(28, 28))
self.treeView.setUniformRowHeights(True)
self.librariesLayout.addWidget(self.treeView)
@@ -142,6 +192,7 @@ class Ui_MainWindow(object):
self.documentTreeView = QTreeView(self.documentDockContents)
self.documentTreeView.setObjectName(u"documentTreeView")
self.documentTreeView.setAlternatingRowColors(True)
self.documentTreeView.setIconSize(QSize(16, 16))
self.documentTreeView.setUniformRowHeights(True)
self.documentPanelLayout.addWidget(self.documentTreeView)
@@ -170,9 +221,21 @@ class Ui_MainWindow(object):
self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2)
self.navigateUpButton = QToolButton(self.workspaceHeader)
self.navigateUpButton.setObjectName(u"navigateUpButton")
icon18 = QIcon()
icon18.addFile(u":/icons/icons/arrow-up.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.navigateUpButton.setIcon(icon18)
self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
self.navigateDownButton = QToolButton(self.workspaceHeader)
self.navigateDownButton.setObjectName(u"navigateDownButton")
self.navigateDownButton.setEnabled(False)
icon19 = QIcon()
icon19.addFile(u":/icons/icons/arrow-down.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.navigateDownButton.setIcon(icon19)
self.workspaceHeaderLayout.addWidget(self.navigateDownButton)
self.graphBreadcrumbLabel = QLabel(self.workspaceHeader)
self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel")
@@ -187,38 +250,66 @@ class Ui_MainWindow(object):
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")
icon20 = QIcon()
icon20.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.pointerToolButton.setIcon(icon20)
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.connectToolButton = QToolButton(self.workspaceHeader)
self.connectToolButton.setObjectName(u"connectToolButton")
icon21 = QIcon()
icon21.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.connectToolButton.setIcon(icon21)
self.connectToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.inputToolButton)
self.workspaceHeaderLayout.addWidget(self.connectToolButton)
self.outputToolButton = QToolButton(self.workspaceHeader)
self.outputToolButton.setObjectName(u"outputToolButton")
self.outputToolButton.setCheckable(True)
self.outputToolButton.setAutoExclusive(True)
self.boxToolButton = QToolButton(self.workspaceHeader)
self.boxToolButton.setObjectName(u"boxToolButton")
icon22 = QIcon()
icon22.addFile(u":/icons/icons/draw-rectangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.boxToolButton.setIcon(icon22)
self.boxToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.outputToolButton)
self.workspaceHeaderLayout.addWidget(self.boxToolButton)
self.lineToolButton = QToolButton(self.workspaceHeader)
self.lineToolButton.setObjectName(u"lineToolButton")
icon23 = QIcon()
icon23.addFile(u":/icons/icons/draw-bezier-curves.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.lineToolButton.setIcon(icon23)
self.lineToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.lineToolButton)
self.textToolButton = QToolButton(self.workspaceHeader)
self.textToolButton.setObjectName(u"textToolButton")
icon24 = QIcon()
icon24.addFile(u":/icons/icons/draw-text.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.textToolButton.setIcon(icon24)
self.textToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.textToolButton)
self.rotateToolButton = QToolButton(self.workspaceHeader)
self.rotateToolButton.setObjectName(u"rotateToolButton")
self.rotateToolButton.setIcon(icon7)
self.workspaceHeaderLayout.addWidget(self.rotateToolButton)
self.workspaceEditorLayout.addWidget(self.workspaceHeader)
self.workspaceStack = QStackedWidget(self.workspace)
self.workspaceVerticalSplitter = QSplitter(self.workspace)
self.workspaceVerticalSplitter.setObjectName(u"workspaceVerticalSplitter")
self.workspaceVerticalSplitter.setOrientation(Qt.Orientation.Vertical)
self.workspaceVerticalSplitter.setChildrenCollapsible(False)
self.workspaceStack = QStackedWidget(self.workspaceVerticalSplitter)
self.workspaceStack.setObjectName(u"workspaceStack")
self.graphPage = QWidget()
self.graphPage.setObjectName(u"graphPage")
@@ -231,31 +322,50 @@ class Ui_MainWindow(object):
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.textPage = QWidget()
self.textPage.setObjectName(u"textPage")
self.textPageLayout = QVBoxLayout(self.textPage)
self.textPageLayout.setObjectName(u"textPageLayout")
self.textPageLayout.setContentsMargins(0, 0, 0, 0)
self.textDefinitionEditor = TextDefinitionEditor(self.textPage)
self.textDefinitionEditor.setObjectName(u"textDefinitionEditor")
self.jsonPageLayout.addWidget(self.jsonEditor)
self.textPageLayout.addWidget(self.textDefinitionEditor)
self.workspaceStack.addWidget(self.jsonPage)
self.workspaceStack.addWidget(self.textPage)
self.emptyPage = QWidget()
self.emptyPage.setObjectName(u"emptyPage")
self.emptyPage.setStyleSheet(u"background-color: #9a9a9a;")
self.emptyPageLayout = QVBoxLayout(self.emptyPage)
self.emptyPageLayout.setObjectName(u"emptyPageLayout")
self.emptyWorkspaceLabel = QLabel(self.emptyPage)
self.emptyWorkspaceLabel.setObjectName(u"emptyWorkspaceLabel")
self.emptyWorkspaceLabel.setStyleSheet(u"background: transparent; color: #202020;")
self.emptyWorkspaceLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyPageLayout.addWidget(self.emptyWorkspaceLabel)
self.workspaceStack.addWidget(self.emptyPage)
self.workspaceVerticalSplitter.addWidget(self.workspaceStack)
self.outputTabs = QTabWidget(self.workspaceVerticalSplitter)
self.outputTabs.setObjectName(u"outputTabs")
self.outputTabs.setMinimumSize(QSize(0, 100))
self.logTab = QWidget()
self.logTab.setObjectName(u"logTab")
self.logTabLayout = QVBoxLayout(self.logTab)
self.logTabLayout.setObjectName(u"logTabLayout")
self.logTabLayout.setContentsMargins(0, 0, 0, 0)
self.logOutput = QPlainTextEdit(self.logTab)
self.logOutput.setObjectName(u"logOutput")
self.logOutput.setReadOnly(True)
self.logOutput.setMaximumBlockCount(5000)
self.workspaceEditorLayout.addWidget(self.workspaceStack)
self.logTabLayout.addWidget(self.logOutput)
self.outputTabs.addTab(self.logTab, "")
self.workspaceVerticalSplitter.addWidget(self.outputTabs)
self.workspaceEditorLayout.addWidget(self.workspaceVerticalSplitter)
self.workspaceSplitter.addWidget(self.workspace)
@@ -264,7 +374,7 @@ class Ui_MainWindow(object):
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1000, 24))
self.menubar.setGeometry(QRect(0, 0, 1209, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuEdit = QMenu(self.menubar)
@@ -277,6 +387,8 @@ class Ui_MainWindow(object):
self.menuToolbars.setObjectName(u"menuToolbars")
self.menuHelp = QMenu(self.menubar)
self.menuHelp.setObjectName(u"menuHelp")
self.menuSimulation = QMenu(self.menubar)
self.menuSimulation.setObjectName(u"menuSimulation")
MainWindow.setMenuBar(self.menubar)
self.fileToolbar = QToolBar(MainWindow)
self.fileToolbar.setObjectName(u"fileToolbar")
@@ -292,16 +404,23 @@ class Ui_MainWindow(object):
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.cameraToolbar = QToolBar(MainWindow)
self.cameraToolbar.setObjectName(u"cameraToolbar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.cameraToolbar)
self.simulationToolbar = QToolBar(MainWindow)
self.simulationToolbar.setObjectName(u"simulationToolbar")
self.simulationToolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.simulationToolbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuSimulation.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionNew)
self.menuFile.addAction(self.actionOpen)
self.menuFile.addAction(self.actionReloadLibraries)
self.menuFile.addAction(self.actionReloadSimulation)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
@@ -322,6 +441,12 @@ class Ui_MainWindow(object):
self.menuView.addAction(self.menuToolbars.menuAction())
self.menuHelp.addAction(self.actionAbout)
self.menuHelp.addAction(self.actionAboutQt)
self.menuSimulation.addAction(self.actionSimulationSettings)
self.menuSimulation.addAction(self.actionGraphParameters)
self.menuSimulation.addAction(self.actionCompose)
self.menuSimulation.addAction(self.actionExportModel)
self.menuSimulation.addAction(self.actionSimulationWindow)
self.menuSimulation.addAction(self.actionRunSimulation)
self.fileToolbar.addAction(self.actionNew)
self.fileToolbar.addAction(self.actionOpen)
self.fileToolbar.addAction(self.actionSave)
@@ -331,7 +456,14 @@ class Ui_MainWindow(object):
self.editToolbar.addAction(self.actionCopy)
self.editToolbar.addAction(self.actionCut)
self.editToolbar.addAction(self.actionPaste)
self.transformToolbar.addAction(self.actionRotateClockwise)
self.cameraToolbar.addAction(self.actionZoomIn)
self.cameraToolbar.addAction(self.actionZoomOut)
self.cameraToolbar.addAction(self.actionCenterView)
self.simulationToolbar.addAction(self.actionSimulationSettings)
self.simulationToolbar.addAction(self.actionGraphParameters)
self.simulationToolbar.addAction(self.actionCompose)
self.simulationToolbar.addAction(self.actionSimulationWindow)
self.simulationToolbar.addAction(self.actionRunSimulation)
self.retranslateUi(MainWindow)
@@ -343,6 +475,36 @@ class Ui_MainWindow(object):
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"BEdit", None))
self.actionSimulationSettings.setText(QCoreApplication.translate("MainWindow", u"Simulation Settings", None))
#if QT_CONFIG(statustip)
self.actionSimulationSettings.setStatusTip(QCoreApplication.translate("MainWindow", u"Edit settings stored in the active graph", None))
#endif // QT_CONFIG(statustip)
self.actionGraphParameters.setText(QCoreApplication.translate("MainWindow", u"Graph Parameters", None))
#if QT_CONFIG(statustip)
self.actionGraphParameters.setStatusTip(QCoreApplication.translate("MainWindow", u"Edit parameters throughout the active graph", None))
#endif // QT_CONFIG(statustip)
self.actionCompose.setText(QCoreApplication.translate("MainWindow", u"Compose", None))
#if QT_CONFIG(statustip)
self.actionCompose.setStatusTip(QCoreApplication.translate("MainWindow", u"Compose the active graph as an OpenModelica model", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionCompose.setShortcut(QCoreApplication.translate("MainWindow", u"F5", None))
#endif // QT_CONFIG(shortcut)
self.actionRunSimulation.setText(QCoreApplication.translate("MainWindow", u"Run", None))
#if QT_CONFIG(statustip)
self.actionRunSimulation.setStatusTip(QCoreApplication.translate("MainWindow", u"Run the simulation", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionRunSimulation.setShortcut(QCoreApplication.translate("MainWindow", u"F6", None))
#endif // QT_CONFIG(shortcut)
self.actionSimulationWindow.setText(QCoreApplication.translate("MainWindow", u"Simulation Window", None))
#if QT_CONFIG(statustip)
self.actionSimulationWindow.setStatusTip(QCoreApplication.translate("MainWindow", u"Show the simulation results window", None))
#endif // QT_CONFIG(statustip)
self.actionExportModel.setText(QCoreApplication.translate("MainWindow", u"Export Model\u2026", None))
#if QT_CONFIG(statustip)
self.actionExportModel.setStatusTip(QCoreApplication.translate("MainWindow", u"Save the composed OpenModelica model to a file", None))
#endif // QT_CONFIG(statustip)
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))
@@ -356,6 +518,18 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionRotateClockwise.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+R", None))
#endif // QT_CONFIG(shortcut)
self.actionZoomIn.setText(QCoreApplication.translate("MainWindow", u"Zoom In", None))
#if QT_CONFIG(shortcut)
self.actionZoomIn.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl++", None))
#endif // QT_CONFIG(shortcut)
self.actionZoomOut.setText(QCoreApplication.translate("MainWindow", u"Zoom Out", None))
#if QT_CONFIG(shortcut)
self.actionZoomOut.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+-", None))
#endif // QT_CONFIG(shortcut)
self.actionCenterView.setText(QCoreApplication.translate("MainWindow", u"Center", None))
#if QT_CONFIG(shortcut)
self.actionCenterView.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+0", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None))
#if QT_CONFIG(statustip)
@@ -363,6 +537,20 @@ class Ui_MainWindow(object):
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionOpen.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionReloadLibraries.setText(QCoreApplication.translate("MainWindow", u"Reload &Libraries", None))
#if QT_CONFIG(statustip)
self.actionReloadLibraries.setStatusTip(QCoreApplication.translate("MainWindow", u"Reload configured library files from disk", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionReloadLibraries.setShortcut(QCoreApplication.translate("MainWindow", u"Shift+F5", None))
#endif // QT_CONFIG(shortcut)
self.actionReloadSimulation.setText(QCoreApplication.translate("MainWindow", u"Reload &Simulation Code", None))
#if QT_CONFIG(statustip)
self.actionReloadSimulation.setStatusTip(QCoreApplication.translate("MainWindow", u"Reload the simulation package while preserving runtime state", None))
#endif // QT_CONFIG(statustip)
#if QT_CONFIG(shortcut)
self.actionReloadSimulation.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+F5", None))
#endif // QT_CONFIG(shortcut)
self.actionSave.setText(QCoreApplication.translate("MainWindow", u"&Save", None))
#if QT_CONFIG(statustip)
@@ -419,32 +607,47 @@ class Ui_MainWindow(object):
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.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
#if QT_CONFIG(tooltip)
self.navigateDownButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the selected block", None))
#endif // QT_CONFIG(tooltip)
self.navigateDownButton.setText(QCoreApplication.translate("MainWindow", u"Down", None))
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))
self.connectToolButton.setText(QCoreApplication.translate("MainWindow", u"Connect", None))
#if QT_CONFIG(tooltip)
self.inputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface input", None))
self.boxToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Draw a box annotation", None))
#endif // QT_CONFIG(tooltip)
self.outputToolButton.setText(QCoreApplication.translate("MainWindow", u"Output", None))
self.boxToolButton.setText(QCoreApplication.translate("MainWindow", u"Box", None))
#if QT_CONFIG(tooltip)
self.outputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface output", None))
self.lineToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Draw a line annotation", None))
#endif // QT_CONFIG(tooltip)
self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None))
self.lineToolButton.setText(QCoreApplication.translate("MainWindow", u"Line", None))
#if QT_CONFIG(tooltip)
self.textToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add a text annotation", None))
#endif // QT_CONFIG(tooltip)
self.textToolButton.setText(QCoreApplication.translate("MainWindow", u"Text", None))
#if QT_CONFIG(tooltip)
self.rotateToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise", None))
#endif // QT_CONFIG(tooltip)
self.rotateToolButton.setText(QCoreApplication.translate("MainWindow", u"Rotate", None))
self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None))
self.logOutput.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Application messages appear here.", None))
self.outputTabs.setTabText(self.outputTabs.indexOf(self.logTab), QCoreApplication.translate("MainWindow", u"Log", 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.menuSimulation.setTitle(QCoreApplication.translate("MainWindow", u"Simulation", 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))
self.cameraToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Camera", None))
self.simulationToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Simulation", None))
# retranslateUi

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'parameter_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, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
QPushButton, QSizePolicy, QSpinBox, QSplitter,
QVBoxLayout, QWidget)
class Ui_ParameterOptionsDialog(object):
def setupUi(self, ParameterOptionsDialog):
if not ParameterOptionsDialog.objectName():
ParameterOptionsDialog.setObjectName(u"ParameterOptionsDialog")
ParameterOptionsDialog.resize(720, 500)
self.dialogLayout = QVBoxLayout(ParameterOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.parameterSplitter = QSplitter(ParameterOptionsDialog)
self.parameterSplitter.setObjectName(u"parameterSplitter")
self.parameterSplitter.setOrientation(Qt.Orientation.Horizontal)
self.parameterListPanel = QWidget(self.parameterSplitter)
self.parameterListPanel.setObjectName(u"parameterListPanel")
self.parameterListLayout = QVBoxLayout(self.parameterListPanel)
self.parameterListLayout.setObjectName(u"parameterListLayout")
self.parameterListLayout.setContentsMargins(0, 0, 0, 0)
self.parameterList = QListWidget(self.parameterListPanel)
self.parameterList.setObjectName(u"parameterList")
self.parameterListLayout.addWidget(self.parameterList)
self.parameterButtonsLayout = QHBoxLayout()
self.parameterButtonsLayout.setObjectName(u"parameterButtonsLayout")
self.addParameterButton = QPushButton(self.parameterListPanel)
self.addParameterButton.setObjectName(u"addParameterButton")
self.parameterButtonsLayout.addWidget(self.addParameterButton)
self.removeParameterButton = QPushButton(self.parameterListPanel)
self.removeParameterButton.setObjectName(u"removeParameterButton")
self.parameterButtonsLayout.addWidget(self.removeParameterButton)
self.parameterListLayout.addLayout(self.parameterButtonsLayout)
self.parameterSplitter.addWidget(self.parameterListPanel)
self.parameterDetailsPanel = QWidget(self.parameterSplitter)
self.parameterDetailsPanel.setObjectName(u"parameterDetailsPanel")
self.parameterDetailsForm = QFormLayout(self.parameterDetailsPanel)
self.parameterDetailsForm.setObjectName(u"parameterDetailsForm")
self.parameterDetailsForm.setContentsMargins(0, 0, 0, 0)
self.nameLabel = QLabel(self.parameterDetailsPanel)
self.nameLabel.setObjectName(u"nameLabel")
self.parameterDetailsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
self.nameEdit = QLineEdit(self.parameterDetailsPanel)
self.nameEdit.setObjectName(u"nameEdit")
self.parameterDetailsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.typeLabel = QLabel(self.parameterDetailsPanel)
self.typeLabel.setObjectName(u"typeLabel")
self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel)
self.typeCombo = QComboBox(self.parameterDetailsPanel)
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.setObjectName(u"typeCombo")
self.typeCombo.setEditable(True)
self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.typeCombo)
self.rowsLabel = QLabel(self.parameterDetailsPanel)
self.rowsLabel.setObjectName(u"rowsLabel")
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.rowsLabel)
self.rowsSpin = QSpinBox(self.parameterDetailsPanel)
self.rowsSpin.setObjectName(u"rowsSpin")
self.rowsSpin.setMinimum(1)
self.rowsSpin.setMaximum(9999)
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.rowsSpin)
self.columnsLabel = QLabel(self.parameterDetailsPanel)
self.columnsLabel.setObjectName(u"columnsLabel")
self.parameterDetailsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.columnsLabel)
self.columnsSpin = QSpinBox(self.parameterDetailsPanel)
self.columnsSpin.setObjectName(u"columnsSpin")
self.columnsSpin.setMinimum(1)
self.columnsSpin.setMaximum(9999)
self.parameterDetailsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.columnsSpin)
self.valueLabel = QLabel(self.parameterDetailsPanel)
self.valueLabel.setObjectName(u"valueLabel")
self.parameterDetailsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.valueLabel)
self.valueEdit = QLineEdit(self.parameterDetailsPanel)
self.valueEdit.setObjectName(u"valueEdit")
self.parameterDetailsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.valueEdit)
self.quantityLabel = QLabel(self.parameterDetailsPanel)
self.quantityLabel.setObjectName(u"quantityLabel")
self.parameterDetailsForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.quantityLabel)
self.quantityCombo = QComboBox(self.parameterDetailsPanel)
self.quantityCombo.setObjectName(u"quantityCombo")
self.quantityCombo.setEditable(True)
self.parameterDetailsForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.quantityCombo)
self.unitLabel = QLabel(self.parameterDetailsPanel)
self.unitLabel.setObjectName(u"unitLabel")
self.parameterDetailsForm.setWidget(6, QFormLayout.ItemRole.LabelRole, self.unitLabel)
self.unitCombo = QComboBox(self.parameterDetailsPanel)
self.unitCombo.setObjectName(u"unitCombo")
self.unitCombo.setEditable(True)
self.parameterDetailsForm.setWidget(6, QFormLayout.ItemRole.FieldRole, self.unitCombo)
self.descriptionLabel = QLabel(self.parameterDetailsPanel)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.parameterDetailsForm.setWidget(7, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
self.descriptionEdit = QPlainTextEdit(self.parameterDetailsPanel)
self.descriptionEdit.setObjectName(u"descriptionEdit")
self.descriptionEdit.setMaximumHeight(100)
self.parameterDetailsForm.setWidget(7, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
self.parameterSplitter.addWidget(self.parameterDetailsPanel)
self.dialogLayout.addWidget(self.parameterSplitter)
self.buttonBox = QDialogButtonBox(ParameterOptionsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(ParameterOptionsDialog)
self.buttonBox.accepted.connect(ParameterOptionsDialog.accept)
self.buttonBox.rejected.connect(ParameterOptionsDialog.reject)
QMetaObject.connectSlotsByName(ParameterOptionsDialog)
# setupUi
def retranslateUi(self, ParameterOptionsDialog):
ParameterOptionsDialog.setWindowTitle(QCoreApplication.translate("ParameterOptionsDialog", u"Parameter Options", None))
self.addParameterButton.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Add Parameter", None))
self.removeParameterButton.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Remove Parameter", None))
self.nameLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Name:", None))
self.typeLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Type:", None))
self.typeCombo.setItemText(0, QCoreApplication.translate("ParameterOptionsDialog", u"real", None))
self.typeCombo.setItemText(1, QCoreApplication.translate("ParameterOptionsDialog", u"integer", None))
self.typeCombo.setItemText(2, QCoreApplication.translate("ParameterOptionsDialog", u"boolean", None))
self.typeCombo.setItemText(3, QCoreApplication.translate("ParameterOptionsDialog", u"string", None))
self.rowsLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Rows:", None))
self.columnsLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Columns:", None))
self.valueLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Value:", None))
self.quantityLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Quantity:", None))
self.unitLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Unit:", None))
self.descriptionLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Description:", None))
# retranslateUi

View File

@@ -0,0 +1,310 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'port_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, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem,
QPlainTextEdit, QPushButton, QSizePolicy, QSpinBox,
QSplitter, QStackedWidget, QVBoxLayout, QWidget)
class Ui_PortOptionsDialog(object):
def setupUi(self, PortOptionsDialog):
if not PortOptionsDialog.objectName():
PortOptionsDialog.setObjectName(u"PortOptionsDialog")
PortOptionsDialog.resize(760, 421)
self.dialogLayout = QVBoxLayout(PortOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.portSplitter = QSplitter(PortOptionsDialog)
self.portSplitter.setObjectName(u"portSplitter")
self.portSplitter.setOrientation(Qt.Orientation.Horizontal)
self.portListPanel = QWidget(self.portSplitter)
self.portListPanel.setObjectName(u"portListPanel")
self.portListLayout = QVBoxLayout(self.portListPanel)
self.portListLayout.setObjectName(u"portListLayout")
self.portListLayout.setContentsMargins(0, 0, 0, 0)
self.portList = QListWidget(self.portListPanel)
self.portList.setObjectName(u"portList")
self.portListLayout.addWidget(self.portList)
self.portButtonsLayout = QHBoxLayout()
self.portButtonsLayout.setObjectName(u"portButtonsLayout")
self.addPortButton = QPushButton(self.portListPanel)
self.addPortButton.setObjectName(u"addPortButton")
self.portButtonsLayout.addWidget(self.addPortButton)
self.removePortButton = QPushButton(self.portListPanel)
self.removePortButton.setObjectName(u"removePortButton")
self.portButtonsLayout.addWidget(self.removePortButton)
self.portListLayout.addLayout(self.portButtonsLayout)
self.portSplitter.addWidget(self.portListPanel)
self.portDetailsPanel = QWidget(self.portSplitter)
self.portDetailsPanel.setObjectName(u"portDetailsPanel")
self.portDetailsForm = QFormLayout(self.portDetailsPanel)
self.portDetailsForm.setObjectName(u"portDetailsForm")
self.portDetailsForm.setVerticalSpacing(3)
self.portDetailsForm.setContentsMargins(0, 0, 0, 0)
self.nameLabel = QLabel(self.portDetailsPanel)
self.nameLabel.setObjectName(u"nameLabel")
self.portDetailsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
self.nameEdit = QLineEdit(self.portDetailsPanel)
self.nameEdit.setObjectName(u"nameEdit")
self.portDetailsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.typeLabel = QLabel(self.portDetailsPanel)
self.typeLabel.setObjectName(u"typeLabel")
self.portDetailsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel)
self.typeCombo = QComboBox(self.portDetailsPanel)
self.typeCombo.addItem("")
self.typeCombo.setObjectName(u"typeCombo")
self.portDetailsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.typeCombo)
self.orientationLabel = QLabel(self.portDetailsPanel)
self.orientationLabel.setObjectName(u"orientationLabel")
self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.orientationLabel)
self.orientationCombo = QComboBox(self.portDetailsPanel)
self.orientationCombo.addItem("")
self.orientationCombo.addItem("")
self.orientationCombo.addItem("")
self.orientationCombo.setObjectName(u"orientationCombo")
self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.orientationCombo)
self.multipleConnectionsCheckBox = QCheckBox(self.portDetailsPanel)
self.multipleConnectionsCheckBox.setObjectName(u"multipleConnectionsCheckBox")
self.portDetailsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.multipleConnectionsCheckBox)
self.positionHintLabel = QLabel(self.portDetailsPanel)
self.positionHintLabel.setObjectName(u"positionHintLabel")
self.positionHintLabel.setWordWrap(True)
self.portDetailsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.typeOptionsStack = QStackedWidget(self.portDetailsPanel)
self.typeOptionsStack.setObjectName(u"typeOptionsStack")
self.signalOptionsPage = QWidget()
self.signalOptionsPage.setObjectName(u"signalOptionsPage")
self.signalOptionsForm = QFormLayout(self.signalOptionsPage)
self.signalOptionsForm.setObjectName(u"signalOptionsForm")
self.valueTypeLabel = QLabel(self.signalOptionsPage)
self.valueTypeLabel.setObjectName(u"valueTypeLabel")
self.signalOptionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.valueTypeLabel)
self.valueTypeCombo = QComboBox(self.signalOptionsPage)
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.setObjectName(u"valueTypeCombo")
self.valueTypeCombo.setEditable(True)
self.signalOptionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.valueTypeCombo)
self.quantityLabel = QLabel(self.signalOptionsPage)
self.quantityLabel.setObjectName(u"quantityLabel")
self.signalOptionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.quantityLabel)
self.quantityCombo = QComboBox(self.signalOptionsPage)
self.quantityCombo.setObjectName(u"quantityCombo")
self.quantityCombo.setEditable(True)
self.signalOptionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.quantityCombo)
self.unitCombo = QComboBox(self.signalOptionsPage)
self.unitCombo.setObjectName(u"unitCombo")
self.unitCombo.setEditable(True)
self.signalOptionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.unitCombo)
self.unitLabel = QLabel(self.signalOptionsPage)
self.unitLabel.setObjectName(u"unitLabel")
self.signalOptionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.unitLabel)
self.rowsColumnsLabel = QLabel(self.signalOptionsPage)
self.rowsColumnsLabel.setObjectName(u"rowsColumnsLabel")
self.signalOptionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.rowsColumnsLabel)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.rowsSpin = QSpinBox(self.signalOptionsPage)
self.rowsSpin.setObjectName(u"rowsSpin")
self.rowsSpin.setMinimum(1)
self.rowsSpin.setMaximum(9999)
self.horizontalLayout.addWidget(self.rowsSpin)
self.columnsSpin = QSpinBox(self.signalOptionsPage)
self.columnsSpin.setObjectName(u"columnsSpin")
self.columnsSpin.setMinimum(1)
self.columnsSpin.setMaximum(9999)
self.horizontalLayout.addWidget(self.columnsSpin)
self.signalOptionsForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.horizontalLayout)
self.descriptionLabel = QLabel(self.signalOptionsPage)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.signalOptionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
self.descriptionEdit = QPlainTextEdit(self.signalOptionsPage)
self.descriptionEdit.setObjectName(u"descriptionEdit")
self.signalOptionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
self.typeOptionsStack.addWidget(self.signalOptionsPage)
self.powerOptionsPage = QWidget()
self.powerOptionsPage.setObjectName(u"powerOptionsPage")
self.powerOptionsForm = QFormLayout(self.powerOptionsPage)
self.powerOptionsForm.setObjectName(u"powerOptionsForm")
self.domainLabel = QLabel(self.powerOptionsPage)
self.domainLabel.setObjectName(u"domainLabel")
self.powerOptionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel)
self.domainCombo = QComboBox(self.powerOptionsPage)
self.domainCombo.setObjectName(u"domainCombo")
self.powerOptionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainCombo)
self.effortLabel = QLabel(self.powerOptionsPage)
self.effortLabel.setObjectName(u"effortLabel")
self.powerOptionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.effortLabel)
self.effortValueLabel = QLabel(self.powerOptionsPage)
self.effortValueLabel.setObjectName(u"effortValueLabel")
self.powerOptionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.effortValueLabel)
self.flowLabel = QLabel(self.powerOptionsPage)
self.flowLabel.setObjectName(u"flowLabel")
self.powerOptionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.flowLabel)
self.flowValueLabel = QLabel(self.powerOptionsPage)
self.flowValueLabel.setObjectName(u"flowValueLabel")
self.powerOptionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.flowValueLabel)
self.causalityLabel = QLabel(self.powerOptionsPage)
self.causalityLabel.setObjectName(u"causalityLabel")
self.powerOptionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.causalityLabel)
self.causalityCombo = QComboBox(self.powerOptionsPage)
self.causalityCombo.setObjectName(u"causalityCombo")
self.powerOptionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.causalityCombo)
self.powerDescriptionLabel = QLabel(self.powerOptionsPage)
self.powerDescriptionLabel.setObjectName(u"powerDescriptionLabel")
self.powerOptionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.powerDescriptionLabel)
self.powerDescriptionEdit = QPlainTextEdit(self.powerOptionsPage)
self.powerDescriptionEdit.setObjectName(u"powerDescriptionEdit")
self.powerOptionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.powerDescriptionEdit)
self.typeOptionsStack.addWidget(self.powerOptionsPage)
self.unsupportedTypePage = QWidget()
self.unsupportedTypePage.setObjectName(u"unsupportedTypePage")
self.unsupportedTypeLayout = QVBoxLayout(self.unsupportedTypePage)
self.unsupportedTypeLayout.setObjectName(u"unsupportedTypeLayout")
self.unsupportedTypeLabel = QLabel(self.unsupportedTypePage)
self.unsupportedTypeLabel.setObjectName(u"unsupportedTypeLabel")
self.unsupportedTypeLabel.setWordWrap(True)
self.unsupportedTypeLayout.addWidget(self.unsupportedTypeLabel)
self.typeOptionsStack.addWidget(self.unsupportedTypePage)
self.portDetailsForm.setWidget(4, QFormLayout.ItemRole.SpanningRole, self.typeOptionsStack)
self.portSplitter.addWidget(self.portDetailsPanel)
self.dialogLayout.addWidget(self.portSplitter)
self.buttonBox = QDialogButtonBox(PortOptionsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(PortOptionsDialog)
self.buttonBox.accepted.connect(PortOptionsDialog.accept)
self.buttonBox.rejected.connect(PortOptionsDialog.reject)
QMetaObject.connectSlotsByName(PortOptionsDialog)
# setupUi
def retranslateUi(self, PortOptionsDialog):
PortOptionsDialog.setWindowTitle(QCoreApplication.translate("PortOptionsDialog", u"Port Options", None))
self.addPortButton.setText(QCoreApplication.translate("PortOptionsDialog", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("PortOptionsDialog", u"Remove Port", None))
self.nameLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Name:", None))
self.typeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Port type:", None))
self.typeCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Signal", None))
self.orientationLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Orientation:", None))
self.orientationCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Input", None))
self.orientationCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"Output", None))
self.orientationCombo.setItemText(2, QCoreApplication.translate("PortOptionsDialog", u"Indifferent", None))
self.multipleConnectionsCheckBox.setText(QCoreApplication.translate("PortOptionsDialog", u"Allow multiple connections", None))
self.positionHintLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"New ports start at (0, 0) in the icon editor.", None))
self.valueTypeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Type:", None))
self.valueTypeCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"real", None))
self.valueTypeCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"integer", None))
self.valueTypeCombo.setItemText(2, QCoreApplication.translate("PortOptionsDialog", u"boolean", None))
self.valueTypeCombo.setItemText(3, QCoreApplication.translate("PortOptionsDialog", u"string", None))
self.quantityLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Quantity:", None))
self.unitLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Unit:", None))
self.rowsColumnsLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Matrix size:", None))
self.descriptionLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Description:", None))
self.domainLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Domain:", None))
self.effortLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Effort:", None))
self.effortValueLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"p.e", None))
self.flowLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Flow:", None))
self.flowValueLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"p.f", None))
self.causalityLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Causality:", None))
self.powerDescriptionLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Description:", None))
self.unsupportedTypeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"This port type does not have an options editor yet.", None))
# retranslateUi

View File

@@ -0,0 +1,304 @@
# -*- 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, QAbstractItemView, QApplication, QCheckBox,
QDialog, QDialogButtonBox, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpacerItem, QSpinBox, QTabWidget, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget)
class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog):
if not SettingsDialog.objectName():
SettingsDialog.setObjectName(u"SettingsDialog")
SettingsDialog.resize(480, 520)
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.editorGridsGroupBox = QGroupBox(self.generalTab)
self.editorGridsGroupBox.setObjectName(u"editorGridsGroupBox")
self.editorGridsLayout = QFormLayout(self.editorGridsGroupBox)
self.editorGridsLayout.setObjectName(u"editorGridsLayout")
self.graphGridLabel = QLabel(self.editorGridsGroupBox)
self.graphGridLabel.setObjectName(u"graphGridLabel")
self.editorGridsLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.graphGridLabel)
self.graphGridSpinBox = QSpinBox(self.editorGridsGroupBox)
self.graphGridSpinBox.setObjectName(u"graphGridSpinBox")
self.graphGridSpinBox.setMinimum(8)
self.graphGridSpinBox.setMaximum(512)
self.graphGridSpinBox.setValue(64)
self.editorGridsLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.graphGridSpinBox)
self.graphSnapLabel = QLabel(self.editorGridsGroupBox)
self.graphSnapLabel.setObjectName(u"graphSnapLabel")
self.editorGridsLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.graphSnapLabel)
self.graphSnapSpinBox = QSpinBox(self.editorGridsGroupBox)
self.graphSnapSpinBox.setObjectName(u"graphSnapSpinBox")
self.graphSnapSpinBox.setMinimum(1)
self.graphSnapSpinBox.setMaximum(128)
self.graphSnapSpinBox.setValue(8)
self.editorGridsLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.graphSnapSpinBox)
self.iconGridLabel = QLabel(self.editorGridsGroupBox)
self.iconGridLabel.setObjectName(u"iconGridLabel")
self.editorGridsLayout.setWidget(2, QFormLayout.ItemRole.LabelRole, self.iconGridLabel)
self.iconGridSpinBox = QSpinBox(self.editorGridsGroupBox)
self.iconGridSpinBox.setObjectName(u"iconGridSpinBox")
self.iconGridSpinBox.setMinimum(1)
self.iconGridSpinBox.setMaximum(64)
self.iconGridSpinBox.setValue(8)
self.editorGridsLayout.setWidget(2, QFormLayout.ItemRole.FieldRole, self.iconGridSpinBox)
self.generalLayout.addWidget(self.editorGridsGroupBox)
self.generalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "")
self.bondGraphTab = QWidget()
self.bondGraphTab.setObjectName(u"bondGraphTab")
self.bondGraphLayout = QVBoxLayout(self.bondGraphTab)
self.bondGraphLayout.setObjectName(u"bondGraphLayout")
self.causalityGroupBox = QGroupBox(self.bondGraphTab)
self.causalityGroupBox.setObjectName(u"causalityGroupBox")
self.causalityLayout = QVBoxLayout(self.causalityGroupBox)
self.causalityLayout.setObjectName(u"causalityLayout")
self.automaticCausalityCheckBox = QCheckBox(self.causalityGroupBox)
self.automaticCausalityCheckBox.setObjectName(u"automaticCausalityCheckBox")
self.automaticCausalityCheckBox.setChecked(True)
self.causalityLayout.addWidget(self.automaticCausalityCheckBox)
self.automaticCausalityHintLabel = QLabel(self.causalityGroupBox)
self.automaticCausalityHintLabel.setObjectName(u"automaticCausalityHintLabel")
self.automaticCausalityHintLabel.setWordWrap(True)
self.causalityLayout.addWidget(self.automaticCausalityHintLabel)
self.bondGraphLayout.addWidget(self.causalityGroupBox)
self.bondGraphSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.bondGraphLayout.addItem(self.bondGraphSpacer)
self.settingsTabs.addTab(self.bondGraphTab, "")
self.simulationTab = QWidget()
self.simulationTab.setObjectName(u"simulationTab")
self.simulationTabLayout = QVBoxLayout(self.simulationTab)
self.simulationTabLayout.setObjectName(u"simulationTabLayout")
self.openModelicaGroupBox = QGroupBox(self.simulationTab)
self.openModelicaGroupBox.setObjectName(u"openModelicaGroupBox")
self.openModelicaLayout = QVBoxLayout(self.openModelicaGroupBox)
self.openModelicaLayout.setObjectName(u"openModelicaLayout")
self.openModelicaPathLabel = QLabel(self.openModelicaGroupBox)
self.openModelicaPathLabel.setObjectName(u"openModelicaPathLabel")
self.openModelicaLayout.addWidget(self.openModelicaPathLabel)
self.openModelicaPathLayout = QHBoxLayout()
self.openModelicaPathLayout.setObjectName(u"openModelicaPathLayout")
self.openModelicaPathEdit = QLineEdit(self.openModelicaGroupBox)
self.openModelicaPathEdit.setObjectName(u"openModelicaPathEdit")
self.openModelicaPathLayout.addWidget(self.openModelicaPathEdit)
self.browseOpenModelicaButton = QPushButton(self.openModelicaGroupBox)
self.browseOpenModelicaButton.setObjectName(u"browseOpenModelicaButton")
self.openModelicaPathLayout.addWidget(self.browseOpenModelicaButton)
self.openModelicaLayout.addLayout(self.openModelicaPathLayout)
self.openModelicaHintLabel = QLabel(self.openModelicaGroupBox)
self.openModelicaHintLabel.setObjectName(u"openModelicaHintLabel")
self.openModelicaHintLabel.setWordWrap(True)
self.openModelicaLayout.addWidget(self.openModelicaHintLabel)
self.simulationTabLayout.addWidget(self.openModelicaGroupBox)
self.simulationSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.simulationTabLayout.addItem(self.simulationSpacer)
self.settingsTabs.addTab(self.simulationTab, "")
self.syntaxTab = QWidget()
self.syntaxTab.setObjectName(u"syntaxTab")
self.syntaxTabLayout = QVBoxLayout(self.syntaxTab)
self.syntaxTabLayout.setObjectName(u"syntaxTabLayout")
self.syntaxHintLabel = QLabel(self.syntaxTab)
self.syntaxHintLabel.setObjectName(u"syntaxHintLabel")
self.syntaxHintLabel.setWordWrap(True)
self.syntaxTabLayout.addWidget(self.syntaxHintLabel)
self.syntaxStylesTable = QTableWidget(self.syntaxTab)
if (self.syntaxStylesTable.columnCount() < 4):
self.syntaxStylesTable.setColumnCount(4)
__qtablewidgetitem = QTableWidgetItem()
self.syntaxStylesTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem()
self.syntaxStylesTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem()
self.syntaxStylesTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
__qtablewidgetitem3 = QTableWidgetItem()
self.syntaxStylesTable.setHorizontalHeaderItem(3, __qtablewidgetitem3)
self.syntaxStylesTable.setObjectName(u"syntaxStylesTable")
self.syntaxStylesTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.syntaxStylesTable.setColumnCount(4)
self.syntaxTabLayout.addWidget(self.syntaxStylesTable)
self.settingsTabs.addTab(self.syntaxTab, "")
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.editorGridsGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Editor grids", None))
self.graphGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Workspace grid size:", None))
self.graphGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None))
self.graphSnapLabel.setText(QCoreApplication.translate("SettingsDialog", u"Workspace snapping size:", None))
self.graphSnapSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None))
self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None))
self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
self.causalityGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Causality", None))
self.automaticCausalityCheckBox.setText(QCoreApplication.translate("SettingsDialog", u"Infer causality when connections are added or removed", None))
self.automaticCausalityHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"When disabled, displayed causalities are updated only when compiling, exporting, or running the model.", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.bondGraphTab), QCoreApplication.translate("SettingsDialog", u"Bond graph", None))
self.openModelicaGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"OpenModelica", None))
self.openModelicaPathLabel.setText(QCoreApplication.translate("SettingsDialog", u"OpenModelica executable:", None))
self.openModelicaPathEdit.setPlaceholderText(QCoreApplication.translate("SettingsDialog", u"Leave empty to find omc on PATH", None))
self.browseOpenModelicaButton.setText(QCoreApplication.translate("SettingsDialog", u"Browse\u2026", None))
self.openModelicaHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"Select the omc executable, for example ~/.local/bin/omc.", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.simulationTab), QCoreApplication.translate("SettingsDialog", u"Simulation", None))
self.syntaxHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"Double-click a colour cell to choose a colour. Word lists remain editable in data/syntax/openmodelica.json.", None))
___qtablewidgetitem = self.syntaxStylesTable.horizontalHeaderItem(0)
___qtablewidgetitem.setText(QCoreApplication.translate("SettingsDialog", u"Expression type", None))
___qtablewidgetitem1 = self.syntaxStylesTable.horizontalHeaderItem(1)
___qtablewidgetitem1.setText(QCoreApplication.translate("SettingsDialog", u"Colour", None))
___qtablewidgetitem2 = self.syntaxStylesTable.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("SettingsDialog", u"Bold", None))
___qtablewidgetitem3 = self.syntaxStylesTable.horizontalHeaderItem(3)
___qtablewidgetitem3.setText(QCoreApplication.translate("SettingsDialog", u"Italic", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.syntaxTab), QCoreApplication.translate("SettingsDialog", u"Text highlighting", None))
self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON or BEdit Binary 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,194 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'shape_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, QComboBox, QDialog,
QDialogButtonBox, QDoubleSpinBox, QFormLayout, QLabel,
QLineEdit, QPushButton, QSizePolicy, QSpacerItem,
QVBoxLayout, QWidget)
class Ui_ShapeOptionsDialog(object):
def setupUi(self, ShapeOptionsDialog):
if not ShapeOptionsDialog.objectName():
ShapeOptionsDialog.setObjectName(u"ShapeOptionsDialog")
ShapeOptionsDialog.resize(420, 440)
self.dialogLayout = QVBoxLayout(ShapeOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.optionsForm = QFormLayout()
self.optionsForm.setObjectName(u"optionsForm")
self.widthLabel = QLabel(ShapeOptionsDialog)
self.widthLabel.setObjectName(u"widthLabel")
self.optionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.widthLabel)
self.widthSpin = QDoubleSpinBox(ShapeOptionsDialog)
self.widthSpin.setObjectName(u"widthSpin")
self.widthSpin.setMinimum(1.000000000000000)
self.widthSpin.setMaximum(500.000000000000000)
self.optionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.widthSpin)
self.heightLabel = QLabel(ShapeOptionsDialog)
self.heightLabel.setObjectName(u"heightLabel")
self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.heightLabel)
self.heightSpin = QDoubleSpinBox(ShapeOptionsDialog)
self.heightSpin.setObjectName(u"heightSpin")
self.heightSpin.setMinimum(1.000000000000000)
self.heightSpin.setMaximum(500.000000000000000)
self.optionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.heightSpin)
self.lineStyleLabel = QLabel(ShapeOptionsDialog)
self.lineStyleLabel.setObjectName(u"lineStyleLabel")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.lineStyleLabel)
self.lineStyleCombo = QComboBox(ShapeOptionsDialog)
self.lineStyleCombo.addItem("")
self.lineStyleCombo.addItem("")
self.lineStyleCombo.addItem("")
self.lineStyleCombo.addItem("")
self.lineStyleCombo.addItem("")
self.lineStyleCombo.setObjectName(u"lineStyleCombo")
self.optionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.lineStyleCombo)
self.lineWidthLabel = QLabel(ShapeOptionsDialog)
self.lineWidthLabel.setObjectName(u"lineWidthLabel")
self.optionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.lineWidthLabel)
self.lineWidthSpin = QDoubleSpinBox(ShapeOptionsDialog)
self.lineWidthSpin.setObjectName(u"lineWidthSpin")
self.lineWidthSpin.setMinimum(0.100000000000000)
self.lineWidthSpin.setMaximum(20.000000000000000)
self.lineWidthSpin.setSingleStep(0.500000000000000)
self.optionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.lineWidthSpin)
self.strokeColorLabel = QLabel(ShapeOptionsDialog)
self.strokeColorLabel.setObjectName(u"strokeColorLabel")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.strokeColorLabel)
self.strokeColorButton = QPushButton(ShapeOptionsDialog)
self.strokeColorButton.setObjectName(u"strokeColorButton")
self.optionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.strokeColorButton)
self.fillTypeLabel = QLabel(ShapeOptionsDialog)
self.fillTypeLabel.setObjectName(u"fillTypeLabel")
self.optionsForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.fillTypeLabel)
self.fillTypeCombo = QComboBox(ShapeOptionsDialog)
self.fillTypeCombo.addItem("")
self.fillTypeCombo.addItem("")
self.fillTypeCombo.setObjectName(u"fillTypeCombo")
self.optionsForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.fillTypeCombo)
self.fillColorLabel = QLabel(ShapeOptionsDialog)
self.fillColorLabel.setObjectName(u"fillColorLabel")
self.optionsForm.setWidget(6, QFormLayout.ItemRole.LabelRole, self.fillColorLabel)
self.fillColorButton = QPushButton(ShapeOptionsDialog)
self.fillColorButton.setObjectName(u"fillColorButton")
self.optionsForm.setWidget(6, QFormLayout.ItemRole.FieldRole, self.fillColorButton)
self.cornerRadiusLabel = QLabel(ShapeOptionsDialog)
self.cornerRadiusLabel.setObjectName(u"cornerRadiusLabel")
self.optionsForm.setWidget(7, QFormLayout.ItemRole.LabelRole, self.cornerRadiusLabel)
self.cornerRadiusSpin = QDoubleSpinBox(ShapeOptionsDialog)
self.cornerRadiusSpin.setObjectName(u"cornerRadiusSpin")
self.cornerRadiusSpin.setMaximum(50.000000000000000)
self.optionsForm.setWidget(7, QFormLayout.ItemRole.FieldRole, self.cornerRadiusSpin)
self.textLabel = QLabel(ShapeOptionsDialog)
self.textLabel.setObjectName(u"textLabel")
self.optionsForm.setWidget(8, QFormLayout.ItemRole.LabelRole, self.textLabel)
self.textEdit = QLineEdit(ShapeOptionsDialog)
self.textEdit.setObjectName(u"textEdit")
self.optionsForm.setWidget(8, QFormLayout.ItemRole.FieldRole, self.textEdit)
self.fontSizeLabel = QLabel(ShapeOptionsDialog)
self.fontSizeLabel.setObjectName(u"fontSizeLabel")
self.optionsForm.setWidget(9, QFormLayout.ItemRole.LabelRole, self.fontSizeLabel)
self.fontSizeSpin = QDoubleSpinBox(ShapeOptionsDialog)
self.fontSizeSpin.setObjectName(u"fontSizeSpin")
self.fontSizeSpin.setMinimum(4.000000000000000)
self.fontSizeSpin.setMaximum(96.000000000000000)
self.optionsForm.setWidget(9, QFormLayout.ItemRole.FieldRole, self.fontSizeSpin)
self.dialogLayout.addLayout(self.optionsForm)
self.verticalSpacer = QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.dialogLayout.addItem(self.verticalSpacer)
self.buttonBox = QDialogButtonBox(ShapeOptionsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(ShapeOptionsDialog)
self.buttonBox.accepted.connect(ShapeOptionsDialog.accept)
self.buttonBox.rejected.connect(ShapeOptionsDialog.reject)
QMetaObject.connectSlotsByName(ShapeOptionsDialog)
# setupUi
def retranslateUi(self, ShapeOptionsDialog):
ShapeOptionsDialog.setWindowTitle(QCoreApplication.translate("ShapeOptionsDialog", u"Shape Options", None))
self.widthLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Width:", None))
self.heightLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Height:", None))
self.lineStyleLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Line style:", None))
self.lineStyleCombo.setItemText(0, QCoreApplication.translate("ShapeOptionsDialog", u"solid", None))
self.lineStyleCombo.setItemText(1, QCoreApplication.translate("ShapeOptionsDialog", u"dash", None))
self.lineStyleCombo.setItemText(2, QCoreApplication.translate("ShapeOptionsDialog", u"dot", None))
self.lineStyleCombo.setItemText(3, QCoreApplication.translate("ShapeOptionsDialog", u"dash-dot", None))
self.lineStyleCombo.setItemText(4, QCoreApplication.translate("ShapeOptionsDialog", u"none", None))
self.lineWidthLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Line width:", None))
self.strokeColorLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Line colour:", None))
self.strokeColorButton.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Choose\u2026", None))
self.fillTypeLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Fill type:", None))
self.fillTypeCombo.setItemText(0, QCoreApplication.translate("ShapeOptionsDialog", u"solid", None))
self.fillTypeCombo.setItemText(1, QCoreApplication.translate("ShapeOptionsDialog", u"none", None))
self.fillColorLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Fill colour:", None))
self.fillColorButton.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Choose\u2026", None))
self.cornerRadiusLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Corner radius:", None))
self.textLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Text:", None))
self.fontSizeLabel.setText(QCoreApplication.translate("ShapeOptionsDialog", u"Font size:", None))
# retranslateUi

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'simulation_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, QAbstractSpinBox, QApplication, QDialog,
QDialogButtonBox, QDoubleSpinBox, QFormLayout, QGroupBox,
QLabel, QRadioButton, QSizePolicy, QSpacerItem,
QSpinBox, QVBoxLayout, QWidget)
class Ui_SimulationSettingsDialog(object):
def setupUi(self, SimulationSettingsDialog):
if not SimulationSettingsDialog.objectName():
SimulationSettingsDialog.setObjectName(u"SimulationSettingsDialog")
SimulationSettingsDialog.resize(420, 384)
self.dialogLayout = QVBoxLayout(SimulationSettingsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.settingsGroup = QGroupBox(SimulationSettingsDialog)
self.settingsGroup.setObjectName(u"settingsGroup")
self.settingsLayout = QVBoxLayout(self.settingsGroup)
self.settingsLayout.setObjectName(u"settingsLayout")
self.simulationInterval = QGroupBox(self.settingsGroup)
self.simulationInterval.setObjectName(u"simulationInterval")
self.simulationInterval.setEnabled(True)
self.formLayout = QFormLayout(self.simulationInterval)
self.formLayout.setObjectName(u"formLayout")
self.label = QLabel(self.simulationInterval)
self.label.setObjectName(u"label")
self.formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label)
self.startTimeSpinBox = QDoubleSpinBox(self.simulationInterval)
self.startTimeSpinBox.setObjectName(u"startTimeSpinBox")
self.startTimeSpinBox.setEnabled(True)
self.formLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.startTimeSpinBox)
self.label_2 = QLabel(self.simulationInterval)
self.label_2.setObjectName(u"label_2")
self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_2)
self.stopTimeSpinBox = QDoubleSpinBox(self.simulationInterval)
self.stopTimeSpinBox.setObjectName(u"stopTimeSpinBox")
self.stopTimeSpinBox.setValue(1.000000000000000)
self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.stopTimeSpinBox)
self.numberOfIntervalsRadioButton = QRadioButton(self.simulationInterval)
self.numberOfIntervalsRadioButton.setObjectName(u"numberOfIntervalsRadioButton")
self.formLayout.setWidget(2, QFormLayout.ItemRole.LabelRole, self.numberOfIntervalsRadioButton)
self.intervalTimeRadioButton = QRadioButton(self.simulationInterval)
self.intervalTimeRadioButton.setObjectName(u"intervalTimeRadioButton")
self.formLayout.setWidget(3, QFormLayout.ItemRole.LabelRole, self.intervalTimeRadioButton)
self.numberOfIntervalsSpinBox = QSpinBox(self.simulationInterval)
self.numberOfIntervalsSpinBox.setObjectName(u"numberOfIntervalsSpinBox")
self.numberOfIntervalsSpinBox.setMaximum(999999999)
self.numberOfIntervalsSpinBox.setValue(500)
self.formLayout.setWidget(2, QFormLayout.ItemRole.FieldRole, self.numberOfIntervalsSpinBox)
self.intervalTimeSpinBox = QDoubleSpinBox(self.simulationInterval)
self.intervalTimeSpinBox.setObjectName(u"intervalTimeSpinBox")
self.intervalTimeSpinBox.setDecimals(5)
self.intervalTimeSpinBox.setStepType(QAbstractSpinBox.StepType.AdaptiveDecimalStepType)
self.intervalTimeSpinBox.setValue(0.002000000000000)
self.formLayout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.intervalTimeSpinBox)
self.settingsLayout.addWidget(self.simulationInterval)
self.settingsSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.settingsLayout.addItem(self.settingsSpacer)
self.dialogLayout.addWidget(self.settingsGroup)
self.buttonBox = QDialogButtonBox(SimulationSettingsDialog)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.dialogLayout.addWidget(self.buttonBox)
self.retranslateUi(SimulationSettingsDialog)
self.buttonBox.accepted.connect(SimulationSettingsDialog.accept)
self.buttonBox.rejected.connect(SimulationSettingsDialog.reject)
QMetaObject.connectSlotsByName(SimulationSettingsDialog)
# setupUi
def retranslateUi(self, SimulationSettingsDialog):
SimulationSettingsDialog.setWindowTitle(QCoreApplication.translate("SimulationSettingsDialog", u"Simulation Settings", None))
self.settingsGroup.setTitle(QCoreApplication.translate("SimulationSettingsDialog", u"Settings", None))
self.simulationInterval.setTitle(QCoreApplication.translate("SimulationSettingsDialog", u"Simulation Interval", None))
self.label.setText(QCoreApplication.translate("SimulationSettingsDialog", u"Start time:", None))
self.startTimeSpinBox.setPrefix("")
self.startTimeSpinBox.setSuffix(QCoreApplication.translate("SimulationSettingsDialog", u"s", None))
self.label_2.setText(QCoreApplication.translate("SimulationSettingsDialog", u"Stop time:", None))
self.stopTimeSpinBox.setSuffix(QCoreApplication.translate("SimulationSettingsDialog", u"s", None))
self.numberOfIntervalsRadioButton.setText(QCoreApplication.translate("SimulationSettingsDialog", u"Number of intervals:", None))
self.intervalTimeRadioButton.setText(QCoreApplication.translate("SimulationSettingsDialog", u"Interval:", None))
self.intervalTimeSpinBox.setSuffix(QCoreApplication.translate("SimulationSettingsDialog", u"s", None))
# retranslateUi

View File

@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'simulation_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 (QAbstractItemView, QApplication, QDockWidget, QHBoxLayout,
QHeaderView, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QProgressBar,
QSizePolicy, QTabWidget, QToolBar, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
from . import resources_rc
class Ui_SimulationWindow(object):
def setupUi(self, SimulationWindow):
if not SimulationWindow.objectName():
SimulationWindow.setObjectName(u"SimulationWindow")
SimulationWindow.resize(900, 650)
icon = QIcon()
icon.addFile(u":/icons/icons/office-chart-line.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
SimulationWindow.setWindowIcon(icon)
self.actionOpen = QAction(SimulationWindow)
self.actionOpen.setObjectName(u"actionOpen")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon1)
self.actionSave = QAction(SimulationWindow)
self.actionSave.setObjectName(u"actionSave")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon2)
self.actionClear = QAction(SimulationWindow)
self.actionClear.setObjectName(u"actionClear")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionClear.setIcon(icon3)
self.actionSaveAs = QAction(SimulationWindow)
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(SimulationWindow)
self.actionExit.setObjectName(u"actionExit")
self.actionAbout = QAction(SimulationWindow)
self.actionAbout.setObjectName(u"actionAbout")
self.actionAboutQt = QAction(SimulationWindow)
self.actionAboutQt.setObjectName(u"actionAboutQt")
self.actionToggleResults = QAction(SimulationWindow)
self.actionToggleResults.setObjectName(u"actionToggleResults")
self.actionToggleResults.setCheckable(True)
self.actionToggleResults.setChecked(True)
self.actionAddGraph = QAction(SimulationWindow)
self.actionAddGraph.setObjectName(u"actionAddGraph")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/list-add.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionAddGraph.setIcon(icon5)
self.actionRemoveGraph = QAction(SimulationWindow)
self.actionRemoveGraph.setObjectName(u"actionRemoveGraph")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/list-remove.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRemoveGraph.setIcon(icon6)
self.centralWidget = QWidget(SimulationWindow)
self.centralWidget.setObjectName(u"centralWidget")
self.resultsLayout = QVBoxLayout(self.centralWidget)
self.resultsLayout.setObjectName(u"resultsLayout")
self.graphTabs = QTabWidget(self.centralWidget)
self.graphTabs.setObjectName(u"graphTabs")
self.graphTabs.setTabsClosable(False)
self.graphTabs.setMovable(True)
self.resultsLayout.addWidget(self.graphTabs)
SimulationWindow.setCentralWidget(self.centralWidget)
self.menuBar = QMenuBar(SimulationWindow)
self.menuBar.setObjectName(u"menuBar")
self.menuBar.setGeometry(QRect(0, 0, 900, 24))
self.menuFile = QMenu(self.menuBar)
self.menuFile.setObjectName(u"menuFile")
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")
self.menuGraph = QMenu(self.menuBar)
self.menuGraph.setObjectName(u"menuGraph")
SimulationWindow.setMenuBar(self.menuBar)
self.workspaceToolbar = QToolBar(SimulationWindow)
self.workspaceToolbar.setObjectName(u"workspaceToolbar")
self.workspaceToolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
SimulationWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.workspaceToolbar)
self.fileToolbar = QToolBar(SimulationWindow)
self.fileToolbar.setObjectName(u"fileToolbar")
self.fileToolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
SimulationWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.fileToolbar)
self.statusDock = QDockWidget(SimulationWindow)
self.statusDock.setObjectName(u"statusDock")
self.statusDockContents = QWidget()
self.statusDockContents.setObjectName(u"statusDockContents")
self.horizontalLayout = QHBoxLayout(self.statusDockContents)
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.timeLabel = QLabel(self.statusDockContents)
self.timeLabel.setObjectName(u"timeLabel")
self.horizontalLayout.addWidget(self.timeLabel)
self.progressBar = QProgressBar(self.statusDockContents)
self.progressBar.setObjectName(u"progressBar")
self.progressBar.setMaximum(10000)
self.progressBar.setValue(0)
self.horizontalLayout.addWidget(self.progressBar)
self.statusLabel = QLabel(self.statusDockContents)
self.statusLabel.setObjectName(u"statusLabel")
self.horizontalLayout.addWidget(self.statusLabel)
self.statusDock.setWidget(self.statusDockContents)
SimulationWindow.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.statusDock)
self.logDock = QDockWidget(SimulationWindow)
self.logDock.setObjectName(u"logDock")
self.logDock.setFloating(False)
self.logDock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetFloatable|QDockWidget.DockWidgetFeature.DockWidgetMovable)
self.logDockContents = QWidget()
self.logDockContents.setObjectName(u"logDockContents")
self.logLayout = QVBoxLayout(self.logDockContents)
self.logLayout.setObjectName(u"logLayout")
self.messageList = QListWidget(self.logDockContents)
self.messageList.setObjectName(u"messageList")
self.messageList.setAlternatingRowColors(True)
self.logLayout.addWidget(self.messageList)
self.logDock.setWidget(self.logDockContents)
SimulationWindow.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.logDock)
self.signalsDock = QDockWidget(SimulationWindow)
self.signalsDock.setObjectName(u"signalsDock")
self.signalsDockContents = QWidget()
self.signalsDockContents.setObjectName(u"signalsDockContents")
self.signalsLayout = QVBoxLayout(self.signalsDockContents)
self.signalsLayout.setObjectName(u"signalsLayout")
self.signalsTree = QTreeWidget(self.signalsDockContents)
self.signalsTree.setObjectName(u"signalsTree")
self.signalsTree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.signalsTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.signalsTree.setHeaderHidden(True)
self.signalsLayout.addWidget(self.signalsTree)
self.signalsDock.setWidget(self.signalsDockContents)
SimulationWindow.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.signalsDock)
self.menuBar.addAction(self.menuFile.menuAction())
self.menuBar.addAction(self.menuView.menuAction())
self.menuBar.addAction(self.menuGraph.menuAction())
self.menuBar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionOpen)
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionSaveAs)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionClear)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExit)
self.menuView.addAction(self.menuPanels.menuAction())
self.menuView.addAction(self.menuToolbars.menuAction())
self.menuHelp.addAction(self.actionAbout)
self.menuHelp.addAction(self.actionAboutQt)
self.menuGraph.addAction(self.actionAddGraph)
self.menuGraph.addAction(self.actionRemoveGraph)
self.workspaceToolbar.addAction(self.actionAddGraph)
self.workspaceToolbar.addAction(self.actionRemoveGraph)
self.fileToolbar.addAction(self.actionClear)
self.fileToolbar.addAction(self.actionOpen)
self.fileToolbar.addAction(self.actionSave)
self.fileToolbar.addAction(self.actionSaveAs)
self.retranslateUi(SimulationWindow)
QMetaObject.connectSlotsByName(SimulationWindow)
# setupUi
def retranslateUi(self, SimulationWindow):
SimulationWindow.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Simulation", None))
self.actionOpen.setText(QCoreApplication.translate("SimulationWindow", u"&Open\u2026", None))
#if QT_CONFIG(shortcut)
self.actionOpen.setShortcut(QCoreApplication.translate("SimulationWindow", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionSave.setText(QCoreApplication.translate("SimulationWindow", u"&Save\u2026", None))
#if QT_CONFIG(shortcut)
self.actionSave.setShortcut(QCoreApplication.translate("SimulationWindow", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionClear.setText(QCoreApplication.translate("SimulationWindow", u"&Clear", None))
self.actionSaveAs.setText(QCoreApplication.translate("SimulationWindow", u"Save &As\u2026", None))
#if QT_CONFIG(shortcut)
self.actionSaveAs.setShortcut(QCoreApplication.translate("SimulationWindow", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionExit.setText(QCoreApplication.translate("SimulationWindow", u"E&xit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("SimulationWindow", u"Ctrl+W", None))
#endif // QT_CONFIG(shortcut)
self.actionAbout.setText(QCoreApplication.translate("SimulationWindow", u"&About Simulation Window", None))
self.actionAboutQt.setText(QCoreApplication.translate("SimulationWindow", u"About &Qt", None))
self.actionToggleResults.setText(QCoreApplication.translate("SimulationWindow", u"Results", None))
self.actionAddGraph.setText(QCoreApplication.translate("SimulationWindow", u"Add Graph", None))
#if QT_CONFIG(statustip)
self.actionAddGraph.setStatusTip(QCoreApplication.translate("SimulationWindow", u"Add a graph workspace tab", None))
#endif // QT_CONFIG(statustip)
self.actionRemoveGraph.setText(QCoreApplication.translate("SimulationWindow", u"Remove Current Graph", None))
#if QT_CONFIG(statustip)
self.actionRemoveGraph.setStatusTip(QCoreApplication.translate("SimulationWindow", u"Remove the current graph workspace tab", None))
#endif // QT_CONFIG(statustip)
self.menuFile.setTitle(QCoreApplication.translate("SimulationWindow", u"&File", None))
self.menuView.setTitle(QCoreApplication.translate("SimulationWindow", u"&View", None))
self.menuPanels.setTitle(QCoreApplication.translate("SimulationWindow", u"&Panels", None))
self.menuToolbars.setTitle(QCoreApplication.translate("SimulationWindow", u"&Toolbars", None))
self.menuHelp.setTitle(QCoreApplication.translate("SimulationWindow", u"&Help", None))
self.menuGraph.setTitle(QCoreApplication.translate("SimulationWindow", u"&Graph", None))
self.workspaceToolbar.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Workspace", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"File", None))
self.statusDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Status", None))
self.timeLabel.setText(QCoreApplication.translate("SimulationWindow", u"Time: 0 s", None))
self.progressBar.setFormat(QCoreApplication.translate("SimulationWindow", u"%p%", None))
self.statusLabel.setText(QCoreApplication.translate("SimulationWindow", u"No simulation has been run yet.", None))
self.logDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Log", None))
self.signalsDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Signals", None))
___qtreewidgetitem = self.signalsTree.headerItem()
___qtreewidgetitem.setText(0, QCoreApplication.translate("SimulationWindow", u"Signal", None))
# retranslateUi

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'text_definition_editor.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 (QApplication, QGroupBox, QHBoxLayout, QSizePolicy,
QSplitter, QVBoxLayout, QWidget)
from bedit.gui.dialogs.parameter_options import ParameterEditor
from bedit.gui.dialogs.port_options import PortEditor
from bedit.gui.editors.openmodelica import OpenModelicaEditor
class Ui_TextDefinitionEditor(object):
def setupUi(self, TextDefinitionEditor):
if not TextDefinitionEditor.objectName():
TextDefinitionEditor.setObjectName(u"TextDefinitionEditor")
TextDefinitionEditor.resize(900, 600)
self.editorLayout = QHBoxLayout(TextDefinitionEditor)
self.editorLayout.setObjectName(u"editorLayout")
self.editorLayout.setContentsMargins(6, 6, 6, 6)
self.columnSplitter = QSplitter(TextDefinitionEditor)
self.columnSplitter.setObjectName(u"columnSplitter")
self.columnSplitter.setOrientation(Qt.Orientation.Horizontal)
self.columnSplitter.setChildrenCollapsible(False)
self.sourceSplitter = QSplitter(self.columnSplitter)
self.sourceSplitter.setObjectName(u"sourceSplitter")
self.sourceSplitter.setOrientation(Qt.Orientation.Vertical)
self.sourceSplitter.setChildrenCollapsible(False)
self.declarationsGroup = QGroupBox(self.sourceSplitter)
self.declarationsGroup.setObjectName(u"declarationsGroup")
self.declarationsLayout = QVBoxLayout(self.declarationsGroup)
self.declarationsLayout.setObjectName(u"declarationsLayout")
self.declarationsEdit = OpenModelicaEditor(self.declarationsGroup)
self.declarationsEdit.setObjectName(u"declarationsEdit")
self.declarationsLayout.addWidget(self.declarationsEdit)
self.sourceSplitter.addWidget(self.declarationsGroup)
self.initialEquationsGroup = QGroupBox(self.sourceSplitter)
self.initialEquationsGroup.setObjectName(u"initialEquationsGroup")
self.initialEquationsLayout = QVBoxLayout(self.initialEquationsGroup)
self.initialEquationsLayout.setObjectName(u"initialEquationsLayout")
self.initialEquationsEdit = OpenModelicaEditor(self.initialEquationsGroup)
self.initialEquationsEdit.setObjectName(u"initialEquationsEdit")
self.initialEquationsLayout.addWidget(self.initialEquationsEdit)
self.sourceSplitter.addWidget(self.initialEquationsGroup)
self.equationsGroup = QGroupBox(self.sourceSplitter)
self.equationsGroup.setObjectName(u"equationsGroup")
self.equationsLayout = QVBoxLayout(self.equationsGroup)
self.equationsLayout.setObjectName(u"equationsLayout")
self.equationsEdit = OpenModelicaEditor(self.equationsGroup)
self.equationsEdit.setObjectName(u"equationsEdit")
self.equationsLayout.addWidget(self.equationsEdit)
self.sourceSplitter.addWidget(self.equationsGroup)
self.columnSplitter.addWidget(self.sourceSplitter)
self.definitionSplitter = QSplitter(self.columnSplitter)
self.definitionSplitter.setObjectName(u"definitionSplitter")
self.definitionSplitter.setOrientation(Qt.Orientation.Vertical)
self.definitionSplitter.setChildrenCollapsible(False)
self.portsGroup = QGroupBox(self.definitionSplitter)
self.portsGroup.setObjectName(u"portsGroup")
self.portsLayout = QVBoxLayout(self.portsGroup)
self.portsLayout.setObjectName(u"portsLayout")
self.portEditor = PortEditor(self.portsGroup)
self.portEditor.setObjectName(u"portEditor")
self.portsLayout.addWidget(self.portEditor)
self.definitionSplitter.addWidget(self.portsGroup)
self.parametersGroup = QGroupBox(self.definitionSplitter)
self.parametersGroup.setObjectName(u"parametersGroup")
self.parametersLayout = QVBoxLayout(self.parametersGroup)
self.parametersLayout.setObjectName(u"parametersLayout")
self.parameterEditor = ParameterEditor(self.parametersGroup)
self.parameterEditor.setObjectName(u"parameterEditor")
self.parametersLayout.addWidget(self.parameterEditor)
self.definitionSplitter.addWidget(self.parametersGroup)
self.columnSplitter.addWidget(self.definitionSplitter)
self.editorLayout.addWidget(self.columnSplitter)
self.retranslateUi(TextDefinitionEditor)
QMetaObject.connectSlotsByName(TextDefinitionEditor)
# setupUi
def retranslateUi(self, TextDefinitionEditor):
self.declarationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Declarations", None))
self.initialEquationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Initial Equations", None))
self.equationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Equations", None))
self.portsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Ports", None))
self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None))
pass
# retranslateUi

View File

@@ -0,0 +1,5 @@
"""Graphics scenes, views, editors, and renderers."""
from bedit.gui.graphics.workspace import GraphWorkspaceView
__all__ = ["GraphWorkspaceView"]

View File

@@ -0,0 +1,35 @@
from dataclasses import dataclass
from typing import Literal
from PySide6.QtCore import Qt
ArrowStyle = Literal["open", "half", "filled"]
@dataclass(frozen=True)
class ConnectionStyle:
color: str = "#285f9e"
selected_color: str = "#f59e0b"
width: float = 2.5
selected_width: float = 4.0
line_style: Qt.PenStyle = Qt.PenStyle.SolidLine
arrow_at_source: bool = False
arrow_at_target: bool = True
arrow_size: float = 10.0
arrow_style: ArrowStyle = "filled"
def __post_init__(self) -> None:
if self.arrow_style not in {"open", "half", "filled"}:
raise ValueError(f"Unknown connection arrow style: {self.arrow_style}")
# This is the intentional code-level styling point for every port/connection type.
CONNECTION_STYLES: dict[str, ConnectionStyle] = {
"signal": ConnectionStyle(),
"power": ConnectionStyle(width=3.0, arrow_style="half", color="#000000", arrow_size=20.0),
}
def connection_style(port_type: str) -> ConnectionStyle:
return CONNECTION_STYLES.get(port_type, ConnectionStyle())

View File

@@ -0,0 +1,47 @@
from PySide6.QtCore import QPointF, QRectF
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsView
from bedit.gui.preferences import application_settings
def icon_grid_size() -> int:
return application_settings().value("grid/iconSize", 8, type=int)
class IconCanvasView(QGraphicsView):
"""Designer-promotable view that paints the icon editor grid."""
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
painter.fillRect(rect, QColor("#ffffff"))
grid = icon_grid_size()
painter.setPen(QPen(QColor("#dbeafe"), 0))
left = int(rect.left()) - int(rect.left()) % grid
top = int(rect.top()) - int(rect.top()) % grid
for x in range(left, int(rect.right()) + grid, grid):
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
for y in range(top, int(rect.bottom()) + grid, grid):
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
def _zoom(self, factor: float) -> None:
target = self.transform().m11() * factor
if 0.25 <= target <= 16.0:
self.scale(factor, factor)
def zoom_in(self) -> None:
self._zoom(1.2)
def zoom_out(self) -> None:
self._zoom(1 / 1.2)
def center_icon(self) -> None:
if self.scene() is not None:
self.fitInView(
self.scene().sceneRect().adjusted(-10, -10, 10, 10),
Qt.AspectRatioMode.KeepAspectRatio,
)
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2)
event.accept()

View File

@@ -0,0 +1,561 @@
from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt, QTimer
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
QButtonGroup,
QDialog,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QInputDialog,
QMenu,
)
from bedit.core.model import Component, Icon, Port
from bedit.gui.generated.ui_icon_editor_dialog import Ui_IconEditorDialog
from bedit.gui.generated.ui_shape_options_dialog import Ui_ShapeOptionsDialog
from bedit.gui.graphics.icon_canvas import icon_grid_size
from bedit.gui.graphics.icon_renderer import shape_pen
def _icon_grid_size() -> int:
return icon_grid_size()
def _snap(value: float) -> float:
grid = _icon_grid_size()
return round(value / grid) * grid
class ResizeHandle(QGraphicsEllipseItem):
CURSORS = {
"n": Qt.CursorShape.SizeVerCursor,
"s": Qt.CursorShape.SizeVerCursor,
"e": Qt.CursorShape.SizeHorCursor,
"w": Qt.CursorShape.SizeHorCursor,
"nw": Qt.CursorShape.SizeFDiagCursor,
"se": Qt.CursorShape.SizeFDiagCursor,
"ne": Qt.CursorShape.SizeBDiagCursor,
"sw": Qt.CursorShape.SizeBDiagCursor,
}
def __init__(self, owner: "ShapeItem", role: str = "se") -> None:
super().__init__(-4, -4, 8, 8, owner)
self.owner = owner
self.role = role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setCursor(self.CURSORS[role])
self.setZValue(20)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.begin_resize()
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.resize_from_handle(self.role, event.scenePos())
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.finish_resize()
event.accept()
class ShapeOptionsDialog(QDialog):
def __init__(self, element: dict, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_ShapeOptionsDialog()
self.ui.setupUi(self)
self.element = deepcopy(element)
self.stroke_color = self.element.get("stroke", "#303030")
fill = self.element.get("fill", "#ffffff")
self.fill_color = "#ffffff" if fill in {"none", "transparent", ""} else fill
self.ui.widthSpin.setValue(float(self.element.get("width", 20)))
self.ui.heightSpin.setValue(float(self.element.get("height", 20)))
self.ui.lineStyleCombo.setCurrentText(self.element.get("lineStyle", "solid"))
self.ui.lineWidthSpin.setValue(float(self.element.get("lineWidth", 1.5)))
self.ui.fillTypeCombo.setCurrentText(
"none" if fill in {"none", "transparent", ""} else "solid"
)
self.ui.cornerRadiusSpin.setValue(float(self.element.get("cornerRadius", 0)))
self.ui.textEdit.setText(str(self.element.get("text", "Text")))
self.ui.fontSizeSpin.setValue(float(self.element.get("fontSize", 12)))
self._show_row(
self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line"
)
self._show_row(
self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line"
)
self._show_row(
self.ui.cornerRadiusLabel,
self.ui.cornerRadiusSpin,
self.element.get("type") == "rectangle",
)
is_text = self.element.get("type") == "text"
self._show_row(self.ui.textLabel, self.ui.textEdit, is_text)
self._show_row(self.ui.fontSizeLabel, self.ui.fontSizeSpin, is_text)
self.ui.strokeColorButton.clicked.connect(self._choose_stroke)
self.ui.fillColorButton.clicked.connect(self._choose_fill)
self._refresh_color_buttons()
@staticmethod
def _show_row(label, field, visible: bool) -> None:
label.setVisible(visible)
field.setVisible(visible)
def _choose_color(self, current: str) -> str:
color = QColorDialog.getColor(
QColor(current),
self,
"Choose colour",
QColorDialog.ColorDialogOption.ShowAlphaChannel,
)
if not color.isValid():
return current
return color.name(QColor.NameFormat.HexArgb) if color.alpha() < 255 else color.name()
def _choose_stroke(self) -> None:
self.stroke_color = self._choose_color(self.stroke_color)
self._refresh_color_buttons()
def _choose_fill(self) -> None:
self.fill_color = self._choose_color(self.fill_color)
self._refresh_color_buttons()
def _refresh_color_buttons(self) -> None:
for button, color in (
(self.ui.strokeColorButton, self.stroke_color),
(self.ui.fillColorButton, self.fill_color),
):
button.setText(color)
button.setStyleSheet(f"QPushButton {{ background: {color}; }}")
def accept(self) -> None:
self.element["lineStyle"] = self.ui.lineStyleCombo.currentText()
self.element["lineWidth"] = self.ui.lineWidthSpin.value()
self.element["stroke"] = self.stroke_color
self.element["width"] = self.ui.widthSpin.value()
self.element["height"] = self.ui.heightSpin.value()
if self.element.get("type") != "line":
self.element["fill"] = (
self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
)
if self.element.get("type") == "rectangle":
self.element["cornerRadius"] = self.ui.cornerRadiusSpin.value()
if self.element.get("type") == "text":
self.element["text"] = self.ui.textEdit.text()
self.element["fontSize"] = self.ui.fontSizeSpin.value()
self.element["color"] = self.stroke_color
super().accept()
class ShapeItem(QGraphicsObject):
def __init__(self, element: dict) -> None:
super().__init__()
self.element = element
self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
roles = (
("n", "ne", "e", "se", "s", "sw", "w", "nw")
if element.get("type") == "rectangle"
else ("se",)
)
self.resize_handles = [ResizeHandle(self, role) for role in roles]
self.resize_start: dict | None = None
self._position_resize_handles()
for handle in self.resize_handles:
handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
return (
QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
.normalized()
.adjusted(-margin, -margin, margin, margin)
)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
painter.setPen(shape_pen(self.element))
fill = self.element.get("fill", "none")
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
kind = self.element.get("type")
if kind == "rectangle":
radius = float(self.element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
elif kind in {"circle", "ellipse"}:
painter.drawEllipse(rect)
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(
QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()])
)
elif kind == "text":
painter.setPen(QColor(self.element.get("color", "#202020")))
font = painter.font()
font.setPointSizeF(float(self.element.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(
rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text"))
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
painter.drawRect(rect)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
value = self._bounded_position(value)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
for handle in self.resize_handles:
handle.setVisible(bool(value))
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.setPos(self._bounded_position(self.pos()))
def _bounded_position(self, position: QPointF) -> QPointF:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
minimum_x, maximum_x = min(0.0, width), max(0.0, width)
minimum_y, maximum_y = min(0.0, height), max(0.0, height)
return QPointF(
max(bounds.left() - minimum_x, min(bounds.right() - maximum_x, _snap(position.x()))),
max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))),
)
def _position_resize_handles(self) -> None:
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
positions = {
"n": QPointF(width / 2, 0),
"ne": QPointF(width, 0),
"e": QPointF(width, height / 2),
"se": QPointF(width, height),
"s": QPointF(width / 2, height),
"sw": QPointF(0, height),
"w": QPointF(0, height / 2),
"nw": QPointF(0, 0),
}
for handle in self.resize_handles:
handle.setPos(positions[handle.role])
def begin_resize(self) -> None:
self.resize_start = {
"left": self.pos().x(),
"top": self.pos().y(),
"right": self.pos().x() + float(self.element.get("width", 20)),
"bottom": self.pos().y() + float(self.element.get("height", 20)),
}
def resize_from_handle(self, role: str, scene_position: QPointF) -> None:
if self.resize_start is None:
self.begin_resize()
if self.element.get("type") == "line":
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
endpoint = QPointF(
min(bounds.right(), max(bounds.left(), _snap(scene_position.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(scene_position.y()))),
)
self.prepareGeometryChange()
self.element["width"] = endpoint.x() - self.pos().x()
self.element["height"] = endpoint.y() - self.pos().y()
self._position_resize_handles()
self.update()
return
edges = dict(self.resize_start)
point = QPointF(_snap(scene_position.x()), _snap(scene_position.y()))
if "w" in role:
edges["left"] = min(point.x(), edges["right"] - _icon_grid_size())
if "e" in role:
edges["right"] = max(point.x(), edges["left"] + _icon_grid_size())
if "n" in role:
edges["top"] = min(point.y(), edges["bottom"] - _icon_grid_size())
if "s" in role:
edges["bottom"] = max(point.y(), edges["top"] + _icon_grid_size())
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
edges["left"] = max(bounds.left(), edges["left"])
edges["top"] = max(bounds.top(), edges["top"])
edges["right"] = min(bounds.right(), edges["right"])
edges["bottom"] = min(bounds.bottom(), edges["bottom"])
if self.element.get("type") == "circle":
side = min(edges["right"] - edges["left"], edges["bottom"] - edges["top"])
edges["right"] = edges["left"] + side
edges["bottom"] = edges["top"] + side
self.prepareGeometryChange()
self.element["width"] = edges["right"] - edges["left"]
self.element["height"] = edges["bottom"] - edges["top"]
self.setPos(edges["left"], edges["top"])
self._position_resize_handles()
self.update()
def finish_resize(self) -> None:
self.resize_start = None
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options = menu.addAction("Shape Options…")
delete = menu.addAction("Delete Shape")
chosen = menu.exec(event.screenPos())
if chosen is options:
options_element = deepcopy(self.element)
width_sign = -1 if float(options_element.get("width", 20)) < 0 else 1
height_sign = -1 if float(options_element.get("height", 20)) < 0 else 1
options_element["width"] = abs(float(options_element.get("width", 20)))
options_element["height"] = abs(float(options_element.get("height", 20)))
dialog = ShapeOptionsDialog(options_element)
if dialog.exec() == dialog.DialogCode.Accepted:
self.prepareGeometryChange()
self.element.clear()
self.element.update(dialog.element)
if self.element.get("type") == "line":
self.element["width"] *= width_sign
self.element["height"] *= height_sign
self._position_resize_handles()
self.update()
elif chosen is delete and self.scene() is not None:
self.scene().removeItem(self)
self.element["_deleted"] = True
event.accept()
class PortHandle(QGraphicsEllipseItem):
def __init__(self, port: Port, direction: str, position: QPointF) -> None:
super().__init__(-5, -5, 10, 10)
self.port, self.direction = port, direction
self.setPos(position)
self.setBrush(QColor("#16a34a" if direction == "input" else "#dc2626"))
self.setPen(QPen(QColor("#ffffff"), 1.5))
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
self.setZValue(10)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
value = QPointF(
min(bounds.right(), max(bounds.left(), _snap(value.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(value.y()))),
)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()}
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
self.setPos(
min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))),
min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))),
)
class IconDrawingScene(QGraphicsScene):
def __init__(self, editor: "IconEditorDialog", rect: QRectF) -> None:
super().__init__(rect, editor)
self.editor = editor
self.mode = "pointer"
self.start: QPointF | None = None
self.preview: QGraphicsPathItem | None = None
def set_mode(self, mode: str) -> None:
self.mode = mode
self._clear_preview()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.mode == "pointer":
super().mousePressEvent(event)
return
point = self._bounded(event.scenePos())
if self.mode == "text":
text, accepted = QInputDialog.getText(self.editor, "Add Text", "Text:", text="Text")
if accepted:
self.editor.create_shape("text", point, point + QPointF(55, 35), text)
event.accept()
return
self.start = point
self.preview = QGraphicsPathItem()
self.preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
self.preview.setZValue(100)
self.addItem(self.preview)
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None or self.preview is None:
super().mouseMoveEvent(event)
return
end = self._bounded(event.scenePos())
path = QPainterPath()
rect = QRectF(self.start, end).normalized()
if self.mode == "line":
path.moveTo(self.start)
path.lineTo(end)
elif self.mode in {"circle", "ellipse"}:
if self.mode == "circle":
side = min(rect.width(), rect.height())
rect.setSize(QSizeF(side, side))
path.addEllipse(rect)
else:
path.addRect(rect)
self.preview.setPath(path)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None:
super().mouseReleaseEvent(event)
return
end = self._bounded(event.scenePos())
start = self.start
self._clear_preview()
if start != end:
self.editor.create_shape(self.mode, start, end)
event.accept()
def _bounded(self, point: QPointF) -> QPointF:
rect = self.sceneRect()
return QPointF(
min(rect.right(), max(rect.left(), _snap(point.x()))),
min(rect.bottom(), max(rect.top(), _snap(point.y()))),
)
def _clear_preview(self) -> None:
if self.preview is not None:
self.removeItem(self.preview)
self.preview = None
self.start = None
class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_IconEditorDialog()
self.ui.setupUi(self)
self.setWindowTitle(f"Icon Editor — {component.name}")
self.icon = Icon.from_dict(component.icon.to_dict())
self.ports = deepcopy(component.ports)
self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton)
self.ui.pointerButton.clicked.connect(lambda: self.set_draw_mode("pointer"))
for button, kind in (
(self.ui.addRectangleButton, "rectangle"),
(self.ui.addCircleButton, "circle"),
(self.ui.addEllipseButton, "ellipse"),
(self.ui.addLineButton, "line"),
(self.ui.addTriangleButton, "triangle"),
(self.ui.addTextButton, "text"),
):
self.tool_group.addButton(button)
button.clicked.connect(lambda _checked=False, value=kind: self.set_draw_mode(value))
self.ui.deleteSelectedButton.clicked.connect(self.delete_selected)
self.scene = IconDrawingScene(self, QRectF(0, 0, self.icon.width, self.icon.height))
self.ui.iconView.setScene(self.scene)
self.view = self.ui.iconView
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.ui.zoomInButton.clicked.connect(self.view.zoom_in)
self.ui.zoomOutButton.clicked.connect(self.view.zoom_out)
self.ui.centerButton.clicked.connect(self.view.center_icon)
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
input_side = [port for port in self.ports if port.orientation != "output"]
output_side = [port for port in self.ports if port.orientation == "output"]
self._add_ports(input_side, "input", 0.0)
self._add_ports(output_side, "output", self.icon.width)
self._initial_fit_pending = True
def showEvent(self, event) -> None: # noqa: N802 (Qt API name)
super().showEvent(event)
if self._initial_fit_pending:
self._initial_fit_pending = False
QTimer.singleShot(0, self.view.center_icon)
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)
for index, port in enumerate(ports, 1):
saved = port.properties.get("iconPosition", {})
position = QPointF(
float(saved.get("x", default_x)), float(saved.get("y", spacing * index))
)
self.scene.addItem(PortHandle(port, direction, position))
def set_draw_mode(self, mode: str) -> None:
self.scene.set_mode(mode)
self.view.setDragMode(
self.view.DragMode.RubberBandDrag if mode == "pointer" else self.view.DragMode.NoDrag
)
def create_shape(self, kind: str, start: QPointF, end: QPointF, text: str = "Text") -> None:
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
element = {
"type": kind,
"x": left,
"y": top,
"width": max(_icon_grid_size(), right - left),
"height": max(_icon_grid_size(), bottom - top),
"fill": "#dbeafe",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
}
if kind == "circle":
element["width"] = element["height"] = min(element["width"], element["height"])
if kind == "line":
element["x"], element["y"] = start.x(), start.y()
element["width"], element["height"] = end.x() - start.x(), end.y() - start.y()
element["fill"] = "none"
if kind == "rectangle":
element["cornerRadius"] = 0
if kind == "text":
element.update(
{
"text": text,
"fontSize": 12,
"color": "#202020",
"fill": "none",
"lineStyle": "none",
}
)
self.icon.elements.append(element)
item = ShapeItem(element)
self.scene.addItem(item)
item.setSelected(True)
def delete_selected(self) -> None:
for item in self.scene.selectedItems():
if isinstance(item, ShapeItem):
self.scene.removeItem(item)
item.element["_deleted"] = True
def accept(self) -> None:
self.icon.elements = [
element for element in self.icon.elements if not element.pop("_deleted", False)
]
super().accept()

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF
from bedit.document.model import Icon
from bedit.core.model import Icon
def icon_bounds(icon: Icon) -> QRectF:
@@ -13,14 +13,14 @@ def icon_bounds(icon: Icon) -> QRectF:
rect = QRectF(
float(element.get("x", 0)),
float(element.get("y", 0)),
max(0.0, float(element.get("width", 0))),
max(0.0, float(element.get("height", 0))),
)
float(element.get("width", 0)),
float(element.get("height", 0)),
).normalized()
bounds = rect if bounds.isNull() else bounds.united(rect)
return bounds if not bounds.isNull() else QRectF(32, 32, 64, 64)
def _pen(element: dict) -> QPen:
def shape_pen(element: dict) -> QPen:
styles = {
"solid": Qt.PenStyle.SolidLine,
"dash": Qt.PenStyle.DashLine,
@@ -51,12 +51,16 @@ def paint_icon(
for element in icon.elements:
kind = element.get("type", "rectangle")
rect = QRectF(
float(element.get("x", 0)), float(element.get("y", 0)),
float(element.get("width", 20)), float(element.get("height", 20)),
float(element.get("x", 0)),
float(element.get("y", 0)),
float(element.get("width", 20)),
float(element.get("height", 20)),
)
painter.setPen(_pen(element))
painter.setPen(shape_pen(element))
fill = element.get("fill", "#ffffff")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
if kind == "rectangle":
radius = float(element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
@@ -65,7 +69,11 @@ def paint_icon(
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]))
painter.drawPolygon(
QPolygonF(
[QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]
)
)
elif kind == "text":
painter.setPen(QColor(element.get("color", element.get("stroke", "#202020"))))
font = QFont()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,980 @@
import json
from copy import deepcopy
from pathlib import Path
from PySide6.QtCore import QByteArray, QMimeData, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QButtonGroup,
QApplication,
QFileDialog,
QMainWindow,
QMenu,
QMessageBox,
QTabWidget,
)
from bedit.core.model import Component, Connection
from bedit.core.application_log import get_logger
from bedit.core.serializer import DocumentSerializer
from bedit.core.simulation import Simulation
from bedit.gui.controllers.document import DocumentController
from bedit.gui.dialogs.component_options import ComponentOptionsDialog
from bedit.gui.dialogs.graph_parameters import GraphParametersDialog
from bedit.gui.dialogs.item_options import ItemOptionsDialog
from bedit.gui.models.library_repository import LibraryRepository
from bedit.gui.models.library_tree import (
COMPONENT_MIME_TYPE,
COMPONENT_ROLE,
COMPONENT_ID_ROLE,
COMPONENT_INSTANCE_ROLE,
ITEM_KIND_ROLE,
DocumentTreeModel,
LibraryTreeModel,
)
from bedit.gui.graphics.workspace import SELECTION_MIME_TYPE
from bedit.gui.dialogs.settings import SettingsDialog
from bedit.gui.dialogs.port_options import PortOptionsDialog
from bedit.gui.dialogs.parameter_options import ParameterOptionsDialog
from bedit.gui.dialogs.simulation_settings import SimulationSettingsDialog
from bedit.gui.simulation_window import SimulationWindow
from bedit.gui.preferences import application_settings
from bedit.gui.simulation_reload import reload_simulation
from bedit.gui.generated.ui_main_window import Ui_MainWindow
from bedit.gui.application_log import ApplicationLogHandler
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.application_logger = get_logger()
self.log = get_logger("ui")
self.log_handler = ApplicationLogHandler(self.ui.logOutput)
self.application_logger.addHandler(self.log_handler)
self.application_logger.setLevel("INFO")
self.ui.workspaceVerticalSplitter.setSizes([580, 160])
self.log.info("BEdit started")
self.settings = application_settings()
self._applying_text_definition = False
# Keep a Python-owned top-level window. Giving it MainWindow as its Qt
# parent makes some Linux window managers inherit the BEdit window icon.
self._simulation_window = SimulationWindow()
self.libraries = LibraryRepository(self)
self.simulation = Simulation(
openmodelica_path=SettingsDialog.openmodelica_path(self.settings)
)
self.document_controller = DocumentController(self, simulation=self.simulation)
self.library_tree_model = LibraryTreeModel(
self.libraries,
self.document_controller,
self,
)
self.document_tree_model = DocumentTreeModel(self.document_controller, self)
self._configure_models()
QApplication.clipboard().dataChanged.connect(self._update_edit_actions)
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.setTabPosition(
Qt.DockWidgetArea.LeftDockWidgetArea, QTabWidget.TabPosition.North
)
self.ui.leftDockHost.tabifyDockWidget(self.ui.panel_document, self.ui.panel_libraries)
self.ui.panel_document.raise_()
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.ui.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.ui.treeView.customContextMenuRequested.connect(self.show_external_library_context_menu)
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.ui.documentTreeView.clicked.connect(
lambda _index: self._update_edit_actions()
)
self.ui.treeView.clicked.connect(lambda _index: self._update_edit_actions())
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.componentPortOptionsRequested.connect(self.show_component_port_options)
self.ui.graphView.componentParameterOptionsRequested.connect(
self.show_component_parameter_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.graphView.toolModeShortcutRequested.connect(self.set_graph_tool)
self.mode_button_group = QButtonGroup(self)
self.mode_button_group.setExclusive(True)
self.mode_button_group.addButton(self.ui.pointerToolButton)
self.mode_button_group.addButton(self.ui.connectToolButton)
self.mode_button_group.addButton(self.ui.boxToolButton)
self.mode_button_group.addButton(self.ui.lineToolButton)
self.mode_button_group.addButton(self.ui.textToolButton)
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
self.ui.navigateDownButton.clicked.connect(self.navigate_down)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.connectToolButton.clicked.connect(lambda: self.set_graph_tool("connect"))
self.ui.boxToolButton.clicked.connect(lambda: self.set_graph_tool("box"))
self.ui.lineToolButton.clicked.connect(lambda: self.set_graph_tool("line"))
self.ui.textToolButton.clicked.connect(lambda: self.set_graph_tool("text"))
self.ui.rotateToolButton.clicked.connect(self.ui.graphView.rotate_selected)
self.ui.textDefinitionEditor.definitionEdited.connect(
self.apply_text_definition
)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
self.document_controller.textDefinitionChanged.connect(
self._text_definition_changed
)
def _connect_actions(self) -> None:
self.ui.actionNew.triggered.connect(self.new_document)
self.ui.actionOpen.triggered.connect(self.open_document)
self.ui.actionReloadLibraries.triggered.connect(self.reload_libraries)
self.ui.actionReloadSimulation.triggered.connect(self.reload_simulation_code)
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.copy_selection)
self.ui.actionCut.triggered.connect(self.cut_selection)
self.ui.actionPaste.triggered.connect(self.paste_selection)
self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected)
self.addAction(self.ui.actionRotateClockwise)
self.ui.actionZoomIn.triggered.connect(self.ui.graphView.zoom_in)
self.ui.actionZoomOut.triggered.connect(self.ui.graphView.zoom_out)
self.ui.actionCenterView.triggered.connect(self.ui.graphView.center_workspace)
self.ui.actionSimulationSettings.triggered.connect(
self.show_simulation_settings
)
self.ui.actionGraphParameters.triggered.connect(self.show_graph_parameters)
self.ui.actionCompose.triggered.connect(self.compose_active_graph)
self.ui.actionExportModel.triggered.connect(self.export_model)
self.ui.actionSimulationWindow.triggered.connect(self.show_simulation_window)
self.ui.actionRunSimulation.triggered.connect(self.run_simulation)
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.cameraToolbar,
self.ui.simulationToolbar,
):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
def reload_libraries(self) -> None:
self.libraries.load_paths(SettingsDialog.library_paths(self.settings))
self.ui.treeView.expandAll()
self.log.info("Reloaded %d libraries", len(self.libraries.libraries))
if self.libraries.load_warnings:
for warning in self.libraries.load_warnings:
self.log.warning("Library load failed: %s", warning)
QMessageBox.warning(
self,
"Some libraries could not be loaded",
"\n".join(self.libraries.load_warnings),
)
@Slot()
def reload_simulation_code(self) -> None:
try:
replacement = reload_simulation(self.simulation)
except Exception as error:
self.log.exception("Could not reload simulation code")
QMessageBox.critical(self, "Could not reload simulation code", str(error))
return
self.simulation = replacement
self.document_controller.simulation = replacement
self.log.info("Reloaded simulation code")
@Slot()
def show_simulation_settings(self) -> None:
component = self.document_controller.active_component
if component is None or component.implementation_kind != "graph":
return
dialog = SimulationSettingsDialog(component.graph.simulation_settings, self)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.edit_simulation_settings(dialog.settings)
@Slot()
def show_graph_parameters(self) -> None:
component = self.document_controller.active_component
if component is None or component.implementation_kind != "graph":
return
dialog = GraphParametersDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
try:
self.document_controller.edit_graph_parameter_values(
component.id, dialog.parameter_values
)
except ValueError as error:
self.log.error("Could not change graph parameters: %s", error)
QMessageBox.warning(self, "Cannot change graph parameters", str(error))
@Slot()
def compose_active_graph(self) -> None:
try:
self.document_controller.compose_active_graph()
except ValueError as error:
self.log.error("Composition failed: %s", error)
QMessageBox.warning(self, "Cannot compose", str(error))
@Slot()
def export_model(self) -> None:
try:
model_name, source = self.document_controller.compose_active_graph_source()
except ValueError as error:
self.log.error("Model export composition failed: %s", error)
QMessageBox.warning(self, "Cannot Export Model", str(error))
return
file_name, _selected_filter = QFileDialog.getSaveFileName(
self,
"Export OpenModelica Model",
f"{model_name}.mo",
"Modelica Models (*.mo);;All Files (*)",
)
if not file_name:
return
path = Path(file_name)
if not path.suffix:
path = path.with_suffix(".mo")
temporary_path = path.with_suffix(path.suffix + ".tmp")
try:
temporary_path.write_text(source, encoding="utf-8")
temporary_path.replace(path)
except OSError as error:
self.log.error("Could not export model %s: %s", path, error)
QMessageBox.critical(self, "Cannot Export Model", str(error))
return
self.log.info("Exported OpenModelica model to %s", path)
@Slot()
def show_simulation_window(self) -> None:
self._simulation_window.show()
self._simulation_window.raise_()
self._simulation_window.activateWindow()
@Slot()
def run_simulation(self) -> None:
window = self._simulation_window
callbacks = window.begin_run()
self.show_simulation_window()
try:
self.document_controller.run_simulation(*callbacks)
window.prepare_run_model(self.simulation.model_name)
except Exception as error:
self.log.exception("Simulation run failed")
window.report_start_error(error)
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._set_graph_controls_visible(False)
self.ui.actionSimulationSettings.setEnabled(False)
self.ui.actionGraphParameters.setEnabled(False)
self.ui.actionCompose.setEnabled(False)
self.ui.actionExportModel.setEnabled(False)
self.ui.actionRunSimulation.setEnabled(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.actionSimulationSettings.setEnabled(is_graph)
self.ui.actionGraphParameters.setEnabled(is_graph)
self.ui.actionCompose.setEnabled(is_graph)
self.ui.actionExportModel.setEnabled(is_graph)
self.ui.actionRunSimulation.setEnabled(is_graph)
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
self.ui.workspaceStack.setCurrentWidget(
self.ui.graphPage if is_graph else self.ui.textPage
)
self._set_graph_controls_visible(is_graph)
if is_graph:
self.set_graph_tool("pointer")
else:
self._load_text_definition()
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()
)
document_component_selected = bool(
self.ui.documentTreeView.currentIndex().data(COMPONENT_ID_ROLE)
)
library_component_selected = isinstance(
self.ui.treeView.currentIndex().data(COMPONENT_ROLE), dict
)
document_has_focus = self._view_has_focus(self.ui.documentTreeView)
library_has_focus = self._view_has_focus(self.ui.treeView)
self.ui.actionCopy.setEnabled(
(is_graph and has_selection)
or (document_has_focus and document_component_selected)
or (library_has_focus and library_component_selected)
)
self.ui.actionCut.setEnabled(
(is_graph and has_selection)
or (document_has_focus and document_component_selected)
)
self.ui.actionDelete.setEnabled(is_graph and has_selection)
self.ui.actionRotateClockwise.setEnabled(
is_graph and self.ui.graphView.has_selected_components()
)
self.ui.rotateToolButton.setEnabled(
is_graph and self.ui.graphView.has_selected_components()
)
self.ui.actionSelectAll.setEnabled(is_graph)
can_paste_component = QApplication.clipboard().mimeData().hasFormat(
COMPONENT_MIME_TYPE
) or QApplication.clipboard().mimeData().hasFormat(SELECTION_MIME_TYPE)
self.ui.actionPaste.setEnabled(
(is_graph and not document_has_focus and not library_has_focus)
or (
document_has_focus
and self.document_controller.document is not None
and can_paste_component
)
)
self.ui.navigateDownButton.setEnabled(
is_graph and self.ui.graphView.has_single_selected_component()
)
def copy_selection(self) -> bool:
if self._view_has_focus(self.ui.documentTreeView):
return self._copy_tree_component(self.ui.documentTreeView)
if self._view_has_focus(self.ui.treeView):
return self._copy_tree_component(self.ui.treeView)
return self.ui.graphView.copy_selection()
def cut_selection(self) -> None:
if self._view_has_focus(self.ui.documentTreeView):
index = self.ui.documentTreeView.currentIndex()
component_id = index.data(COMPONENT_ID_ROLE)
if component_id and self._copy_tree_component(self.ui.documentTreeView):
self.document_controller.delete_component(component_id)
return
if self._view_has_focus(self.ui.treeView):
self._copy_tree_component(self.ui.treeView)
return
self.ui.graphView.cut_selection()
def paste_selection(self) -> None:
if self._view_has_focus(self.ui.documentTreeView):
self._paste_into_document_tree()
return
self.ui.graphView.paste_selection()
def _copy_tree_component(self, tree) -> bool:
component = tree.currentIndex().data(COMPONENT_ROLE)
if not isinstance(component, dict):
return False
mime_data = QMimeData()
mime_data.setData(
COMPONENT_MIME_TYPE,
QByteArray(json.dumps(component).encode("utf-8")),
)
QApplication.clipboard().setMimeData(mime_data)
return True
def _paste_into_document_tree(self) -> None:
index = self.ui.documentTreeView.currentIndex()
kind = index.data(ITEM_KIND_ROLE)
owner_id = None
if kind == "current-component":
component_id = index.data(COMPONENT_ID_ROLE)
component = (
self.document_controller.document.find_component(component_id)
if self.document_controller.document
else None
)
if component is None or component.implementation_kind != "graph":
QMessageBox.warning(
self, "Cannot Paste", "Select a graph component or the document root."
)
return
owner_id = component.id
elif kind != "current-document":
QMessageBox.warning(
self, "Cannot Paste", "Select a graph component or the document root."
)
return
try:
components, connections = self._clipboard_components()
self.document_controller.paste_components_to(
owner_id, components, connections
)
except ValueError as error:
QMessageBox.warning(self, "Cannot Paste", str(error))
@staticmethod
def _clipboard_components() -> tuple[list[Component], list]:
mime_data = QApplication.clipboard().mimeData()
try:
if mime_data.hasFormat(SELECTION_MIME_TYPE):
payload = json.loads(
bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8")
)
return (
[Component.from_dict(item) for item in payload.get("components", [])],
[Connection.from_dict(item) for item in payload.get("connections", [])],
)
if mime_data.hasFormat(COMPONENT_MIME_TYPE):
payload = json.loads(
bytes(mime_data.data(COMPONENT_MIME_TYPE)).decode("utf-8")
)
return [Component.from_dict(payload)], []
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
raise ValueError("The clipboard does not contain a valid component") from error
raise ValueError("The clipboard does not contain a BEdit component")
@staticmethod
def _view_has_focus(view) -> bool:
focus = QApplication.focusWidget()
return focus is view or (focus is not None and view.isAncestorOf(focus))
@Slot()
def navigate_up(self) -> None:
if self._resolve_source_edits():
self.document_controller.navigate_up()
@Slot()
def navigate_down(self) -> None:
if self._resolve_source_edits():
self.ui.graphView.open_selected_component()
def _set_graph_controls_visible(self, visible: bool) -> None:
for widget in (
self.ui.pointerToolButton,
self.ui.connectToolButton,
self.ui.boxToolButton,
self.ui.lineToolButton,
self.ui.textToolButton,
self.ui.rotateToolButton,
):
widget.setVisible(visible)
def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode(mode)
{
"pointer": self.ui.pointerToolButton,
"connect": self.ui.connectToolButton,
"box": self.ui.boxToolButton,
"line": self.ui.lineToolButton,
"text": self.ui.textToolButton,
}[mode].setChecked(True)
def _load_text_definition(self) -> None:
component = self.document_controller.active_component
if component is None:
return
self.ui.textDefinitionEditor.set_definition(
component.source.get("declarations", ""),
component.source.get("initialEquations", ""),
component.source.get("equations", ""),
component.ports,
component.parameters,
)
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.textDefinitionEditor.is_modified
):
return True
answer = QMessageBox.question(
self,
"Apply text component changes?",
"The text component has unapplied declaration, initial-equation, "
"equation, port, or parameter changes.",
QMessageBox.StandardButton.Apply
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
)
if answer == QMessageBox.StandardButton.Apply:
return self.apply_text_definition()
return answer == QMessageBox.StandardButton.Discard
@Slot()
def apply_text_definition(self) -> bool:
try:
ports = self.ui.textDefinitionEditor.ports
self._applying_text_definition = True
try:
self.document_controller.replace_active_text_definition(
ports,
self.ui.textDefinitionEditor.declarations,
self.ui.textDefinitionEditor.initial_equations,
self.ui.textDefinitionEditor.equations,
self.ui.textDefinitionEditor.parameters,
)
finally:
self._applying_text_definition = False
except (TypeError, ValueError) as error:
self.log.error("Text component update failed: %s", error)
QMessageBox.critical(self, "Invalid text component", str(error))
self._load_text_definition()
return False
self.ui.textDefinitionEditor.set_modified(False)
return True
@Slot(str)
def _text_definition_changed(self, component_id: str) -> None:
component = self.document_controller.active_component
if (
component is not None
and component.id == component_id
and not self._applying_text_definition
):
self._load_text_definition()
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()
self.log.info("Created a new document")
@Slot()
def close_document(self) -> None:
if self._resolve_source_edits() and self._maybe_save():
path = self.document_controller.file_path
self.document_controller.close_document()
self.log.info("Closed document%s", f" {path}" if path else "")
@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 documents (*.bedit.json *.json *.beb);;"
"BEdit JSON (*.bedit.json *.json);;BEdit Binary (*.beb);;All files (*)",
)
if not filename:
return
self.open_document_path(Path(filename), check_unsaved=False)
def open_document_path(self, path: Path, *, check_unsaved: bool = True) -> bool:
"""Open a document path selected by the UI or supplied at startup."""
if check_unsaved and (
not self._resolve_source_edits() or not self._maybe_save()
):
return False
try:
self.document_controller.load(path)
self.log.info("Opened document %s", path)
except (OSError, ValueError) as error:
self.log.error("Could not open document %s: %s", path, error)
QMessageBox.critical(self, "Could not open graph", str(error))
return False
return True
@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:
path = self.document_controller.save()
self.log.info("Saved document %s", path)
except OSError as error:
self.log.error("Could not save document: %s", 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, selected_filter = QFileDialog.getSaveFileName(
self,
"Save graph",
"untitled.beb",
"BEdit Binary (*.beb);;BEdit JSON (*.bedit.json *.json);;All files (*)",
)
if not filename:
return False
path = Path(filename)
if path.suffix.lower() not in {".json", ".beb"}:
path = path.with_suffix(
".beb" if "Binary" in selected_filter else ".bedit.json"
)
try:
path = self.document_controller.save(path)
self.log.info("Saved document %s", path)
except OSError as error:
self.log.error("Could not save document %s: %s", filename, 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.settingsChanged.connect(self.refresh_editor_settings)
dialog.settingsChanged.connect(self.refresh_simulation_preferences)
dialog.exec()
def refresh_simulation_preferences(self) -> None:
self.simulation.openmodelica_path = SettingsDialog.openmodelica_path(
self.settings
)
def refresh_editor_settings(self) -> None:
scene = self.ui.graphView.scene()
if scene is not None:
scene.update()
self.ui.graphView.viewport().update()
self.ui.textDefinitionEditor.ui.declarationsEdit.reload_highlighting()
self.ui.textDefinitionEditor.ui.initialEquationsEdit.reload_highlighting()
self.ui.textDefinitionEditor.ui.equationsEdit.reload_highlighting()
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)
if index.isValid():
tree_view.setCurrentIndex(index)
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…")
ports_action = menu.addAction("Port Options…")
parameters_action = menu.addAction("Parameter Options…")
menu.addSeparator()
copy_action = menu.addAction("Copy")
cut_action = menu.addAction("Cut")
paste_action = menu.addAction("Paste")
paste_action.setEnabled(component.implementation_kind == "graph")
delete_action = menu.addAction("Delete")
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if graph_action is not None and selected is graph_action:
self.document_controller.add_child(component_id, "graph")
elif text_action is not None and 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 ports_action:
self.show_component_port_options(component_id)
elif selected is parameters_action:
self.show_component_parameter_options(component_id)
elif selected is copy_action:
self._copy_tree_component(tree_view)
elif selected is cut_action:
if self._copy_tree_component(tree_view):
self.document_controller.delete_component(component_id)
elif selected is paste_action:
self._paste_into_document_tree()
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")
menu.addSeparator()
paste_action = menu.addAction("Paste")
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")
elif selected is paste_action:
self._paste_into_document_tree()
@Slot(object)
def show_external_library_context_menu(self, position) -> None:
tree = self.ui.treeView
index = tree.indexAt(position)
if index.isValid():
tree.setCurrentIndex(index)
component = index.data(COMPONENT_INSTANCE_ROLE)
if not isinstance(component, Component):
return
menu = QMenu(self)
copy_action = menu.addAction("Copy")
menu.addSeparator()
ports_action = menu.addAction("Port Options…")
parameters_action = menu.addAction("Parameter Options…")
selected = menu.exec(tree.viewport().mapToGlobal(position))
if selected is copy_action:
self._copy_tree_component(tree)
elif selected is ports_action:
dialog = PortOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
old_ports = deepcopy(component.ports)
component.ports = dialog.ports
library = next(
(
library
for library in self.libraries.libraries
if any(item is component for item in library.document.all_components())
),
None,
)
try:
if library is not None:
library.document.validate()
DocumentSerializer.save(library.document, Path(library.source_path))
except (OSError, ValueError) as error:
component.ports = old_ports
self.log.error("Could not change library ports: %s", error)
QMessageBox.warning(self, "Cannot change library ports", str(error))
self.library_tree_model.rebuild()
elif selected is parameters_action:
dialog = ParameterOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
old_parameters = deepcopy(component.parameters)
component.parameters = dialog.parameters
library = next(
(
library
for library in self.libraries.libraries
if any(item is component for item in library.document.all_components())
),
None,
)
try:
if library is not None:
library.document.validate()
DocumentSerializer.save(
library.document, Path(library.source_path)
)
except (OSError, ValueError) as error:
component.parameters = old_parameters
self.log.error("Could not change library parameters: %s", error)
QMessageBox.warning(
self, "Cannot change library parameters", str(error)
)
self.library_tree_model.rebuild()
@Slot(str)
def show_component_port_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 = PortOptionsDialog(component, self)
if dialog.exec() != dialog.DialogCode.Accepted:
return
try:
self.document_controller.edit_component_ports(
component_id, dialog.ports
)
except ValueError as error:
self.log.error("Could not change component ports: %s", error)
QMessageBox.warning(self, "Cannot change ports", str(error))
@Slot(str)
def show_component_parameter_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 = ParameterOptionsDialog(component, self)
if dialog.exec() != dialog.DialogCode.Accepted:
return
try:
self.document_controller.edit_component_parameters(
component_id, dialog.parameters
)
except ValueError as error:
self.log.error("Could not change component parameters: %s", error)
QMessageBox.warning(self, "Cannot change parameters", str(error))
@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:
try:
self.document_controller.edit_component_appearance(
component_id,
dialog.ui.nameEdit.text().strip(),
dialog.edited_icon,
dialog.edited_ports,
dialog.ui.showSubtreeCheckBox.isChecked(),
dialog.ui.showNameCheckBox.isChecked(),
)
except ValueError as error:
self.log.error("Could not rename component: %s", error)
QMessageBox.warning(self, "Cannot rename component", str(error))
@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
port = next((item for item in owner.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,
show_name=bool(connection.properties.get("showName", False)),
)
if dialog.exec() == dialog.DialogCode.Accepted:
self.document_controller.edit_connection_options(
connection_id, dialog.name, dialog.show_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())
self._simulation_window.close()
self.simulation.shutdown()
self.log.info("BEdit closed")
self.application_logger.removeHandler(self.log_handler)
event.accept()

View File

@@ -0,0 +1 @@
"""Qt model/view adapters and repositories."""

View File

@@ -0,0 +1,30 @@
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from bedit.core.libraries import LibraryDocument, library_candidates, load_library_file
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()
for candidate in library_candidates(path):
try:
libraries.append(load_library_file(candidate))
except (OSError, ValueError, KeyError, TypeError) as error:
warnings.append(f"{candidate}: {error}")
self.libraries = libraries
self.load_warnings = warnings
self.librariesChanged.emit()
self.loadWarningsChanged.emit(warnings)

View File

@@ -1,12 +1,12 @@
import json
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, Qt, Signal
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, QSignalBlocker, Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from bedit.document.controller import DocumentController
from bedit.document.model import Component
from bedit.icon_renderer import library_icon
from bedit.library.repository import LibraryRepository
from bedit.core.model import Component
from bedit.gui.controllers.document import DocumentController
from bedit.gui.graphics.icon_renderer import library_icon
from bedit.gui.models.library_repository import LibraryRepository
COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -80,13 +80,31 @@ class DocumentTreeModel(LibraryTreeModel):
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())
controller.documentNameChanged.connect(self._document_name_changed)
self.itemChanged.connect(self._document_item_changed)
self.rebuild()
def _document_item_changed(self, item: QStandardItem) -> None:
if item.data(ITEM_KIND_ROLE) != "current-document":
return
try:
self.controller.rename_document(item.text())
except ValueError:
self.rebuild()
def _document_name_changed(self, name: str) -> None:
root = self.item(0)
if root is not None:
with QSignalBlocker(self):
root.setText(name)
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Document"])
if self.controller.document is not None:
current_root = QStandardItem("Current Document")
current_root = QStandardItem(
str(self.controller.document.metadata.get("name") or "Current Document")
)
current_root.setDragEnabled(False)
current_root.setData("current-document", ITEM_KIND_ROLE)
for component in self.controller.document.roots.values():

View File

@@ -0,0 +1,6 @@
from PySide6.QtCore import QSettings
def application_settings() -> QSettings:
"""Return BEdit's explicit, disk-backed settings store."""
return QSettings("BEdit", "BEdit")

View File

@@ -0,0 +1,36 @@
from copy import deepcopy
import importlib
import pkgutil
from types import ModuleType
SIMULATION_PACKAGE = "bedit.core.simulation"
def _saved_instance_state(instance) -> dict:
state = {}
for name, value in vars(instance).items():
try:
state[name] = deepcopy(value)
except Exception:
state[name] = value
return state
def reload_simulation(current):
"""Reload the simulation package and return a fresh state-preserving instance."""
saved_state = _saved_instance_state(current)
saved_state.pop("openmodelica", None)
current.shutdown(wait=True)
importlib.invalidate_caches()
package = importlib.import_module(SIMULATION_PACKAGE)
discovered: list[ModuleType] = []
for module_info in pkgutil.walk_packages(package.__path__, f"{SIMULATION_PACKAGE}."):
discovered.append(importlib.import_module(module_info.name))
for module in sorted(discovered, key=lambda item: item.__name__.count("."), reverse=True):
importlib.reload(module)
package = importlib.reload(package)
replacement = package.Simulation()
vars(replacement).update(saved_state)
replacement.openmodelica.configure(replacement.openmodelica_path)
return replacement

View File

@@ -0,0 +1,775 @@
import re
from pathlib import Path
from matplotlib.backend_bases import MouseButton
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMainWindow,
QMenu,
QMessageBox,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bedit.core.simulation.openmodelica import SimulationMessage, SimulationProgress
from bedit.core.simulation.results import (
SimulationExecutionResult,
SimulationGraph,
SimulationResults,
SimulationTrace,
load_simulation_results,
save_simulation_results,
)
from bedit.gui.generated.ui_simulation_window import Ui_SimulationWindow
from bedit.gui.preferences import application_settings
class SimulationWindow(QMainWindow):
"""Persistent viewer for live and previously saved simulation results."""
progressReceived = Signal(object)
messageReceived = Signal(object)
simulationFinished = Signal(object)
simulationFailed = Signal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_SimulationWindow()
self.ui.setupUi(self)
self.settings = application_settings()
self._running = False
self._run_generation = 0
self._file_path: Path | None = None
self._rebuilding_graph_tabs = False
self._updating_signal_checks = False
self.results = SimulationResults()
self._connect_actions()
self._populate_view_menu()
self.splitDockWidget(
self.ui.statusDock, self.ui.logDock, Qt.Orientation.Vertical
)
self.ui.statusDock.setFixedHeight(self.ui.statusDock.sizeHint().height())
self._restore_window_layout()
self.progressReceived.connect(self._show_progress)
self.messageReceived.connect(self._show_message)
self.simulationFinished.connect(self._show_finished)
self.simulationFailed.connect(self._show_error)
def _connect_actions(self) -> None:
self.ui.actionOpen.triggered.connect(self.open_results)
self.ui.actionSave.triggered.connect(self.save_results)
self.ui.actionSaveAs.triggered.connect(self.save_results_as)
self.ui.actionClear.triggered.connect(self.clear)
self.ui.actionExit.triggered.connect(self.close)
self.ui.actionAbout.triggered.connect(self.show_about)
self.ui.actionAboutQt.triggered.connect(
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
)
self.ui.actionToggleResults.toggled.connect(self.ui.centralWidget.setVisible)
self.ui.actionAddGraph.triggered.connect(self.add_graph_tab)
self.ui.actionRemoveGraph.triggered.connect(self.remove_current_graph_tab)
self.ui.graphTabs.tabBarDoubleClicked.connect(self.rename_graph_tab)
self.ui.graphTabs.tabBar().tabMoved.connect(self._move_graph_tab)
self.ui.graphTabs.currentChanged.connect(self._current_graph_changed)
self.ui.signalsTree.itemChanged.connect(self._signal_check_changed)
self.ui.signalsTree.customContextMenuRequested.connect(
self.show_signal_context_menu
)
def _populate_view_menu(self) -> None:
self.ui.menuPanels.addAction(self.ui.actionToggleResults)
for panel in (self.ui.statusDock, self.ui.logDock, self.ui.signalsDock):
self.ui.menuPanels.addAction(panel.toggleViewAction())
self.ui.menuToolbars.addAction(self.ui.fileToolbar.toggleViewAction())
self.ui.menuToolbars.addAction(self.ui.workspaceToolbar.toggleViewAction())
def _restore_window_layout(self) -> None:
geometry = self.settings.value("simulationWindow/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = self.settings.value("simulationWindow/state")
if state is not None:
self.restoreState(state)
results_visible = self.settings.value(
"simulationWindow/resultsVisible", True, type=bool
)
self.ui.actionToggleResults.setChecked(results_visible)
def _save_window_layout(self) -> None:
self.settings.setValue("simulationWindow/geometry", self.saveGeometry())
self.settings.setValue("simulationWindow/state", self.saveState())
self.settings.setValue(
"simulationWindow/resultsVisible",
self.ui.actionToggleResults.isChecked(),
)
self.settings.sync()
def begin_run(self) -> tuple:
"""Reset transient run state while retaining the current workspace."""
self._run_generation += 1
generation = self._run_generation
self._running = True
self._file_path = None
self.results.status = {}
self.results.messages.clear()
self.results.metadata.clear()
self.ui.progressBar.setValue(0)
self.ui.timeLabel.setText("Time: 0 s")
self.ui.messageList.clear()
self.ui.statusLabel.setText("Preparing simulation…")
return (
lambda progress: self._report_progress(generation, progress),
lambda message: self._report_message(generation, message),
lambda result: self._report_finished(generation, result),
lambda error: self._report_error(generation, error),
)
def prepare_run_model(self, model_name: str | None) -> None:
"""Retain graph configuration only when rerunning the same model."""
if not model_name:
return
if self.results.model_name != model_name:
self.results = SimulationResults(model_name=model_name)
self.clear_result_views()
self.load_result_views()
else:
self.results.model_name = model_name
self.ui.statusLabel.setText("Preparing simulation…")
self._update_title()
def clear(self, checked: bool = False, *, model_name: str = "") -> None:
"""Discard the displayed run and prepare an empty results document."""
del checked
self._run_generation += 1
self._running = False
self._file_path = None
self.results = SimulationResults(model_name=model_name)
self.ui.statusLabel.setText("No simulation has been run yet.")
self.ui.progressBar.setValue(0)
self.ui.timeLabel.setText("Time: 0 s")
self.ui.messageList.clear()
self.clear_result_views()
self.load_result_views()
self._update_title()
def clear_result_views(self) -> None:
"""Clear custom plots before a run or loaded document is displayed.
Future graph widgets should be placed in the Designer-owned
``resultsLayout`` and reset here.
"""
self._rebuilding_graph_tabs = True
self._clear_graph_tab_widgets()
self._rebuilding_graph_tabs = False
self.ui.signalsTree.clear()
def load_result_views(self) -> None:
"""Populate custom plots from ``self.results.data`` and traces.
This is the intended integration point for a future plotting widget.
"""
self._rebuild_graph_tabs()
self._rebuild_signal_tree()
def _rebuild_graph_tabs(self) -> None:
current_page = self.ui.graphTabs.currentWidget()
current_graph_id = (
current_page.graph_id
if isinstance(current_page, GraphWorkspacePage)
else None
)
self._rebuilding_graph_tabs = True
self._clear_graph_tab_widgets()
for graph in self.results.graphs:
self.ui.graphTabs.addTab(
GraphWorkspacePage(graph, self.results), graph.title
)
if current_graph_id is not None:
for index, graph in enumerate(self.results.graphs):
if graph.id == current_graph_id:
self.ui.graphTabs.setCurrentIndex(index)
break
self._rebuilding_graph_tabs = False
self._update_graph_actions()
self._sync_signal_checks()
def _clear_graph_tab_widgets(self) -> None:
while self.ui.graphTabs.count():
page = self.ui.graphTabs.widget(0)
self.ui.graphTabs.removeTab(0)
page.deleteLater()
def add_graph_tab(self) -> None:
used_titles = {graph.title for graph in self.results.graphs}
number = 1
while f"Graph {number}" in used_titles:
number += 1
graph = SimulationGraph(title=f"Graph {number}")
self.results.graphs.append(graph)
index = self.ui.graphTabs.addTab(
GraphWorkspacePage(graph, self.results), graph.title
)
self.ui.graphTabs.setCurrentIndex(index)
self._update_graph_actions()
def remove_current_graph_tab(self) -> None:
index = self.ui.graphTabs.currentIndex()
if index < 0 or index >= len(self.results.graphs):
return
self.results.graphs.pop(index)
page = self.ui.graphTabs.widget(index)
self.ui.graphTabs.removeTab(index)
page.deleteLater()
self._update_graph_actions()
self._sync_signal_checks()
def rename_graph_tab(self, index: int) -> None:
if index < 0 or index >= len(self.results.graphs):
return
graph = self.results.graphs[index]
title, accepted = QInputDialog.getText(
self, "Rename Graph", "Title:", text=graph.title
)
title = title.strip()
if accepted and title:
graph.title = title
self.ui.graphTabs.setTabText(index, title)
page = self.ui.graphTabs.widget(index)
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def _move_graph_tab(self, old_index: int, new_index: int) -> None:
if self._rebuilding_graph_tabs or old_index == new_index:
return
graph = self.results.graphs.pop(old_index)
self.results.graphs.insert(new_index, graph)
self._sync_signal_checks()
def _update_graph_actions(self) -> None:
self.ui.actionRemoveGraph.setEnabled(bool(self.results.graphs))
def _rebuild_signal_tree(self) -> None:
"""Build a hierarchy while retaining each leaf's complete signal name."""
tree = self.ui.signalsTree
self._updating_signal_checks = True
try:
tree.clear()
items: dict[tuple[str, ...], QTreeWidgetItem] = {}
for signal_name in sorted(self.results.data, key=str.casefold):
parts = _signal_tree_parts(signal_name)
if not parts:
continue
parent = tree.invisibleRootItem()
for depth, part in enumerate(parts, start=1):
path = parts[:depth]
item = items.get(path)
if item is None:
item = QTreeWidgetItem(parent, [part])
items[path] = item
parent = item
parent.setData(0, Qt.ItemDataRole.UserRole, signal_name)
metadata = self.results.signal_metadata.get(signal_name, {})
unit = metadata.get("unit", "")
if unit:
parent.setText(0, f"{parts[-1]} [{unit}]")
tooltip = signal_name
details = [
metadata.get("quantity", ""),
f"Unit: {unit}" if unit else "",
metadata.get("description", ""),
]
details = [detail for detail in details if detail]
if details:
tooltip += "\n" + "\n".join(details)
parent.setToolTip(0, tooltip)
parent.setFlags(parent.flags() | Qt.ItemFlag.ItemIsUserCheckable)
parent.setCheckState(0, Qt.CheckState.Unchecked)
tree.expandToDepth(0)
finally:
self._updating_signal_checks = False
self._sync_signal_checks()
def _current_graph_changed(self, _index: int) -> None:
if not self._rebuilding_graph_tabs:
self._sync_signal_checks()
def _current_graph(self) -> SimulationGraph | None:
index = self.ui.graphTabs.currentIndex()
if 0 <= index < len(self.results.graphs):
return self.results.graphs[index]
return None
def _sync_signal_checks(self) -> None:
graph = self._current_graph()
enabled = {trace.name for trace in graph.traces} if graph else set()
self._updating_signal_checks = True
try:
root = self.ui.signalsTree.invisibleRootItem()
pending = [root.child(index) for index in range(root.childCount())]
while pending:
item = pending.pop()
pending.extend(
item.child(index) for index in range(item.childCount())
)
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(signal_name, str):
item.setCheckState(
0,
Qt.CheckState.Checked
if signal_name in enabled
else Qt.CheckState.Unchecked,
)
finally:
self._updating_signal_checks = False
self.ui.signalsTree.setEnabled(graph is not None)
def _signal_check_changed(self, item: QTreeWidgetItem, _column: int) -> None:
if self._updating_signal_checks:
return
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
graph = self._current_graph()
if not isinstance(signal_name, str) or graph is None:
return
enabled = item.checkState(0) == Qt.CheckState.Checked
existing = next(
(trace for trace in graph.traces if trace.name == signal_name), None
)
if enabled and existing is None:
metadata = self.results.signal_metadata.get(signal_name, {})
graph.traces.append(
SimulationTrace(
name=signal_name,
y_label=metadata.get("quantity", ""),
unit=metadata.get("unit", ""),
)
)
elif not enabled and existing is not None:
graph.traces.remove(existing)
page = self.ui.graphTabs.currentWidget()
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def selected_signal_names(self) -> list[str]:
"""Return full column names selected for future plotting actions."""
names = []
for item in self.ui.signalsTree.selectedItems():
name = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(name, str):
names.append(name)
return names
def show_signal_context_menu(self, position) -> None:
item = self.ui.signalsTree.itemAt(position)
signal_name = (
item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
)
graph = self._current_graph()
if not isinstance(signal_name, str) or graph is None:
return
menu = QMenu(self)
use_as_x_action = menu.addAction("Use as X Axis")
use_as_x_action.setCheckable(True)
use_as_x_action.setChecked(graph.x_axis == signal_name)
selected = menu.exec(
self.ui.signalsTree.viewport().mapToGlobal(position)
)
if selected is use_as_x_action:
self.set_x_axis_signal(signal_name)
def set_x_axis_signal(self, signal_name: str) -> None:
"""Set the current graph's persisted horizontal data column."""
graph = self._current_graph()
if graph is None or signal_name not in self.results.data:
return
graph.x_axis = signal_name
page = self.ui.graphTabs.currentWidget()
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def open_results(self) -> None:
file_name, _selected_filter = QFileDialog.getOpenFileName(
self,
"Open Simulation Results",
"",
"BEdit Binary Simulation Results (*.ber);;JSON Simulation Results (*.json)",
)
if not file_name:
return
try:
results = load_simulation_results(file_name)
except ValueError as error:
QMessageBox.warning(self, "Cannot Open Simulation Results", str(error))
return
self._run_generation += 1
self._running = False
self._file_path = Path(file_name)
self.results = results
self._display_results()
def save_results(self) -> None:
file_path = self._file_path
if file_path is None:
self.save_results_as()
return
self._save_results_to(file_path)
def save_results_as(self) -> None:
default_name = f"{_safe_file_stem(self.results.model_name)}-results.ber"
file_name, selected_filter = QFileDialog.getSaveFileName(
self,
"Save Simulation Results As",
default_name,
"BEdit Binary Simulation Results (*.ber);;JSON Simulation Results (*.json)",
)
if not file_name:
return
file_path = Path(file_name)
if file_path.suffix.lower() not in {".ber", ".json"}:
suffix = ".json" if selected_filter.startswith("JSON") else ".ber"
file_path = file_path.with_suffix(suffix)
self._save_results_to(file_path)
def _save_results_to(self, file_path: Path) -> None:
try:
save_simulation_results(file_path, self.results)
except OSError as error:
QMessageBox.warning(self, "Cannot Save Simulation Results", str(error))
return
self._file_path = file_path
self._update_title()
def _display_results(self) -> None:
status = self.results.status
self.ui.statusLabel.setText(str(status.get("phase", "Loaded results")))
self.ui.progressBar.setValue(int(status.get("progress", 0)))
self.ui.timeLabel.setText(f"Time: {float(status.get('time', 0)):g} s")
self.ui.messageList.clear()
for message in self.results.messages:
prefix = message.get("stream") or message.get("type") or "OpenModelica"
self.ui.messageList.addItem(f"{prefix}: {message.get('text', '')}")
self.clear_result_views()
self.load_result_views()
self._update_title()
def _update_title(self) -> None:
name = self.results.model_name or "Simulation"
self.setWindowTitle(f"{name} — Simulation")
def _report_progress(self, generation: int, progress: SimulationProgress) -> None:
if generation == self._run_generation:
self.progressReceived.emit(progress)
def _report_message(self, generation: int, message: SimulationMessage) -> None:
if generation == self._run_generation:
self.messageReceived.emit(message)
def _report_finished(self, generation: int, result) -> None:
if generation == self._run_generation:
self.simulationFinished.emit(result)
def _report_error(self, generation: int, error: Exception) -> None:
if generation == self._run_generation:
self.simulationFailed.emit(str(error))
def report_start_error(self, error: Exception) -> None:
self.simulationFailed.emit(str(error))
def _show_progress(self, progress: SimulationProgress) -> None:
self.results.status = {
"phase": progress.phase,
"currentStepSize": progress.current_step_size,
"time": progress.time,
"progress": progress.progress,
}
self.ui.statusLabel.setText(progress.phase or "Running")
self.ui.timeLabel.setText(f"Time: {progress.time:g} s")
self.ui.progressBar.setValue(max(0, min(10000, progress.progress)))
def _show_message(self, message: SimulationMessage) -> None:
self.results.messages.append(
{"stream": message.stream, "type": message.type, "text": message.text}
)
prefix = message.stream or message.type or "OpenModelica"
self.ui.messageList.addItem(f"{prefix}: {message.text}")
self.ui.messageList.scrollToBottom()
def _show_finished(self, result) -> None:
self._running = False
self.results.status.update(phase="Simulation finished", progress=10000)
if isinstance(result, SimulationExecutionResult):
self.results.data = result.data
self.results.signal_metadata = result.signal_metadata
available_signals = set(result.data)
for graph in self.results.graphs:
graph.traces = [
trace
for trace in graph.traces
if trace.name in available_signals
]
for trace in graph.traces:
metadata = result.signal_metadata.get(trace.name, {})
trace.unit = metadata.get("unit", "")
trace.y_label = metadata.get("quantity", "")
if graph.x_axis not in available_signals:
graph.x_axis = (
"time"
if "time" in available_signals
else next(iter(result.data), "")
)
self.results.metadata["processReturnCode"] = result.return_code
self.results.metadata["sourceResultFile"] = Path(result.result_file).name
else:
self.results.metadata["processResult"] = result
self.ui.progressBar.setValue(10000)
self.ui.statusLabel.setText("Simulation finished")
self.load_result_views()
def _show_error(self, message: str) -> None:
self._running = False
self.results.status["phase"] = "Simulation failed"
self.results.messages.append(
{"stream": "BEdit", "type": "error", "text": message}
)
self.ui.statusLabel.setText("Simulation failed")
self.ui.messageList.addItem(f"Error: {message}")
def show_about(self) -> None:
QMessageBox.about(
self,
"About BEdit Simulation",
"<h3>BEdit Simulation</h3>"
"<p>View live progress and open or save simulation results.</p>",
)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
self._save_window_layout()
super().closeEvent(event)
@property
def is_running(self) -> bool:
return self._running
class GraphWorkspacePage(QWidget):
"""Matplotlib view of one persisted simulation graph definition."""
def __init__(
self, graph: SimulationGraph, results: SimulationResults, parent=None
) -> None:
super().__init__(parent)
self.graph_id = graph.id
self.graph = graph
self.results = results
self.plot_layout = QVBoxLayout(self)
self.figure = Figure(layout="constrained")
self.canvas = InteractiveFigureCanvas(self.figure)
self.axes = self.figure.add_subplot(111)
self.canvas.axes = self.axes
self.navigation_toolbar = GraphNavigationToolbar(self.canvas, self)
self.canvas.navigation_toolbar = self.navigation_toolbar
self.plot_layout.addWidget(self.navigation_toolbar)
self.plot_layout.addWidget(self.canvas)
self.refresh_chart()
def refresh_chart(self) -> None:
self.axes.clear()
x_values = self.results.data.get(self.graph.x_axis)
for trace in self.graph.traces:
y_values = self.results.data.get(trace.name)
if y_values is None:
continue
horizontal = x_values if x_values is not None else range(len(y_values))
sample_count = min(len(horizontal), len(y_values))
color = trace.properties.get("color")
unit = trace.unit or self.results.signal_metadata.get(
trace.name, {}
).get("unit", "")
label = f"{trace.name} [{unit}]" if unit else trace.name
self.axes.plot(
list(horizontal)[:sample_count],
y_values[:sample_count],
label=label,
color=color if isinstance(color, str) and color else None,
)
self.axes.set_title(self.graph.title)
if x_values is not None:
x_unit = self.results.signal_metadata.get(
self.graph.x_axis, {}
).get("unit", "")
x_label = (
f"{self.graph.x_axis} [{x_unit}]"
if x_unit
else self.graph.x_axis
)
else:
x_label = "sample"
self.axes.set_xlabel(x_label)
units = {
trace.unit
or self.results.signal_metadata.get(trace.name, {}).get("unit", "")
for trace in self.graph.traces
if self.results.data.get(trace.name) is not None
}
units.discard("")
if len(units) == 1:
self.axes.set_ylabel(next(iter(units)))
self.axes.grid(True, alpha=0.25)
if self.axes.lines:
self.axes.legend()
self.axes.relim()
self.axes.autoscale_view()
self.canvas.set_home_view()
self.canvas.draw_idle()
class GraphNavigationToolbar(NavigationToolbar2QT):
"""Navigation toolbar whose Home action includes direct canvas navigation."""
def home(self, *args) -> None:
del args
self.canvas.reset_home_view()
class InteractiveFigureCanvas(FigureCanvasQTAgg):
"""Matplotlib canvas with always-available wheel zoom and drag pan."""
def __init__(self, figure: Figure) -> None:
super().__init__(figure)
self.axes = None
self.navigation_toolbar = None
self._pan_start = None
self._home_view = None
self.mpl_connect("scroll_event", self._zoom_at_cursor)
self.mpl_connect("button_press_event", self._start_pan)
self.mpl_connect("motion_notify_event", self._pan)
self.mpl_connect("button_release_event", self._finish_pan)
def _toolbar_is_active(self) -> bool:
return bool(
self.navigation_toolbar is not None
and self.navigation_toolbar.mode
)
def set_home_view(self) -> None:
if self.axes is not None:
self._home_view = (self.axes.get_xlim(), self.axes.get_ylim())
def reset_home_view(self) -> None:
if self.axes is None or self._home_view is None:
return
x_limits, y_limits = self._home_view
self.axes.set_xlim(x_limits)
self.axes.set_ylim(y_limits)
self.draw_idle()
def _zoom_at_cursor(self, event) -> None:
if (
self.axes is None
or event.inaxes is not self.axes
or event.xdata is None
or event.ydata is None
or self._toolbar_is_active()
):
return
scale = 0.8 if event.button == "up" else 1.25
left, right = self.axes.get_xlim()
bottom, top = self.axes.get_ylim()
self.axes.set_xlim(
event.xdata - (event.xdata - left) * scale,
event.xdata + (right - event.xdata) * scale,
)
self.axes.set_ylim(
event.ydata - (event.ydata - bottom) * scale,
event.ydata + (top - event.ydata) * scale,
)
self.draw_idle()
def _start_pan(self, event) -> None:
if (
self.axes is None
or event.inaxes is not self.axes
or event.button != MouseButton.LEFT
or self._toolbar_is_active()
):
return
self._pan_start = (
event.x,
event.y,
self.axes.get_xlim(),
self.axes.get_ylim(),
)
def _pan(self, event) -> None:
if (
self.axes is None
or self._pan_start is None
or event.x is None
or event.y is None
):
return
start_x, start_y, x_limits, y_limits = self._pan_start
width = max(self.axes.bbox.width, 1.0)
height = max(self.axes.bbox.height, 1.0)
delta_x = (event.x - start_x) * (x_limits[1] - x_limits[0]) / width
delta_y = (event.y - start_y) * (y_limits[1] - y_limits[0]) / height
self.axes.set_xlim(x_limits[0] - delta_x, x_limits[1] - delta_x)
self.axes.set_ylim(y_limits[0] - delta_y, y_limits[1] - delta_y)
self.draw_idle()
def _finish_pan(self, _event) -> None:
self._pan_start = None
def _safe_file_stem(model_name: str) -> str:
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("._")
return stem or "simulation"
def _signal_tree_parts(signal_name: str) -> tuple[str, ...]:
"""Split a result name into readable component, variable, and index levels.
OpenModelica emits state derivatives as names such as ``der(block.x)``.
Keep those traces available, but display them below a ``Derivatives`` group
on their owning component instead of creating a misleading top-level
``der(block`` branch.
"""
derivative = re.fullmatch(r"der\((.+)\)", signal_name)
if derivative is not None:
inner_name = derivative.group(1)
inner_parts = _signal_tree_parts(inner_name)
if inner_parts:
index_count = len(re.findall(r"\[[^\]]+\]", inner_name.rsplit(".", 1)[-1]))
variable_index = max(0, len(inner_parts) - index_count - 1)
return (
*inner_parts[:variable_index],
"Derivatives",
*inner_parts[variable_index:],
)
return ("Derivatives", signal_name)
parts: list[str] = []
for segment in signal_name.split("."):
if not segment:
continue
match = re.fullmatch(r"([^\[]+)((?:\[[^\]]+\])+)", segment)
if match is None:
parts.append(segment)
continue
parts.append(match.group(1))
parts.extend(re.findall(r"\[([^\]]+)\]", match.group(2)))
return tuple(parts)

View File

@@ -1,380 +0,0 @@
from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, QSettings, Qt
from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QGraphicsView,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from bedit.document.model import Component, Icon, Port
from bedit.icon_renderer import _pen
def _icon_grid_size() -> int:
return QSettings().value("grid/iconSize", 8, type=int)
def _snap(value: float) -> float:
grid = _icon_grid_size()
return round(value / grid) * grid
class IconEditorView(QGraphicsView):
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
painter.fillRect(rect, QColor("#ffffff"))
grid = _icon_grid_size()
painter.setPen(QPen(QColor("#dbeafe"), 0))
left = int(rect.left()) - int(rect.left()) % grid
top = int(rect.top()) - int(rect.top()) % grid
for x in range(left, int(rect.right()) + grid, grid):
painter.drawLine(x, rect.top(), x, rect.bottom())
for y in range(top, int(rect.bottom()) + grid, grid):
painter.drawLine(rect.left(), y, rect.right(), y)
class ResizeHandle(QGraphicsEllipseItem):
def __init__(self, owner: "ShapeItem") -> None:
super().__init__(-4, -4, 8, 8, owner)
self.owner = owner
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setZValue(20)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
maximum = (
self.owner.scene().sceneRect().bottomRight() - self.owner.pos()
if self.owner.scene()
else QPointF(128, 128)
)
value = QPointF(
min(maximum.x(), max(_icon_grid_size(), _snap(value.x()))),
min(maximum.y(), max(_icon_grid_size(), _snap(value.y()))),
)
if self.owner.element.get("type") == "circle":
side = min(maximum.x(), maximum.y(), max(value.x(), value.y()))
value = QPointF(side, side)
self.owner.prepareGeometryChange()
self.owner.element["width"] = value.x()
self.owner.element["height"] = value.y()
self.owner.update()
return super().itemChange(change, value)
class ColorButton(QPushButton):
def __init__(self, color: str, allow_none: bool = False, parent=None) -> None:
super().__init__(parent)
self.color = color
self.allow_none = allow_none
self.clicked.connect(self.choose)
self._refresh()
def _refresh(self) -> None:
self.setText("No fill" if self.color == "none" else self.color)
swatch = "transparent" if self.color == "none" else self.color
self.setStyleSheet(f"QPushButton {{ background: {swatch}; }}")
def choose(self) -> None:
initial = QColor("#ffffff" if self.color == "none" else self.color)
color = QColorDialog.getColor(initial, self, "Choose colour", QColorDialog.ColorDialogOption.ShowAlphaChannel)
if color.isValid():
self.color = color.name(QColor.NameFormat.HexArgb) if color.alpha() < 255 else color.name()
self._refresh()
class ShapeOptionsDialog(QDialog):
def __init__(self, element: dict, parent=None) -> None:
super().__init__(parent)
self.element = deepcopy(element)
self.setWindowTitle("Shape Options")
layout = QVBoxLayout(self)
form = QFormLayout()
self.line_style = QComboBox()
self.line_style.addItems(["solid", "dash", "dot", "dash-dot", "none"])
self.line_style.setCurrentText(self.element.get("lineStyle", "solid"))
self.line_width = QDoubleSpinBox()
self.line_width.setRange(0.1, 20.0)
self.line_width.setValue(float(self.element.get("lineWidth", 1.5)))
self.stroke = ColorButton(self.element.get("stroke", "#303030"))
self.fill_type = QComboBox()
self.fill_type.addItems(["solid", "none"])
fill = self.element.get("fill", "#ffffff")
self.fill_type.setCurrentText("none" if fill in {"none", "transparent", ""} else "solid")
self.fill = ColorButton("#ffffff" if fill in {"none", "transparent", ""} else fill)
self.width = QDoubleSpinBox()
self.width.setRange(1, 500)
self.width.setValue(float(self.element.get("width", 20)))
self.height = QDoubleSpinBox()
self.height.setRange(1, 500)
self.height.setValue(float(self.element.get("height", 20)))
form.addRow("Width:", self.width)
form.addRow("Height:", self.height)
form.addRow("Line style:", self.line_style)
form.addRow("Line width:", self.line_width)
form.addRow("Line colour:", self.stroke)
if self.element.get("type") != "line":
form.addRow("Fill type:", self.fill_type)
form.addRow("Fill colour:", self.fill)
self.radius = None
if self.element.get("type") == "rectangle":
self.radius = QDoubleSpinBox()
self.radius.setRange(0, 50)
self.radius.setValue(float(self.element.get("cornerRadius", 0)))
form.addRow("Corner radius:", self.radius)
self.text_edit = None
self.font_size = None
if self.element.get("type") == "text":
self.text_edit = QLineEdit(str(self.element.get("text", "Text")))
self.font_size = QDoubleSpinBox()
self.font_size.setRange(4, 96)
self.font_size.setValue(float(self.element.get("fontSize", 12)))
form.addRow("Text:", self.text_edit)
form.addRow("Font size:", self.font_size)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def accept(self) -> None:
self.element["lineStyle"] = self.line_style.currentText()
self.element["lineWidth"] = self.line_width.value()
self.element["stroke"] = self.stroke.color
self.element["width"] = self.width.value()
self.element["height"] = self.height.value()
if self.element.get("type") != "line":
self.element["fill"] = self.fill.color if self.fill_type.currentText() == "solid" else "none"
if self.radius is not None:
self.element["cornerRadius"] = self.radius.value()
if self.text_edit is not None:
self.element["text"] = self.text_edit.text()
self.element["fontSize"] = self.font_size.value()
self.element["color"] = self.stroke.color
super().accept()
class ShapeItem(QGraphicsObject):
def __init__(self, element: dict) -> None:
super().__init__()
self.element = element
self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.resize_handle = ResizeHandle(self)
self.resize_handle.setPos(float(element.get("width", 20)), float(element.get("height", 20)))
self.resize_handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
return QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))).adjusted(-margin, -margin, margin, margin)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20)))
painter.setPen(_pen(self.element))
fill = self.element.get("fill", "none")
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
kind = self.element.get("type")
if kind == "rectangle":
radius = float(self.element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
elif kind in {"circle", "ellipse"}:
painter.drawEllipse(rect)
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()]))
elif kind == "text":
painter.setPen(QColor(self.element.get("color", "#202020")))
font = painter.font()
font.setPointSizeF(float(self.element.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text")))
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
painter.drawRect(rect)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
value = QPointF(
max(
bounds.left(),
min(
bounds.right() - float(self.element.get("width", 20)),
_snap(value.x()),
),
),
max(
bounds.top(),
min(
bounds.bottom() - float(self.element.get("height", 20)),
_snap(value.y()),
),
),
)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self.resize_handle.setVisible(bool(value))
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
self.setPos(
max(bounds.left(), min(bounds.right() - float(self.element.get("width", 20)), _snap(self.pos().x()))),
max(bounds.top(), min(bounds.bottom() - float(self.element.get("height", 20)), _snap(self.pos().y()))),
)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options = menu.addAction("Shape Options…")
delete = menu.addAction("Delete Shape")
chosen = menu.exec(event.screenPos())
if chosen is options:
dialog = ShapeOptionsDialog(self.element)
if dialog.exec() == dialog.DialogCode.Accepted:
self.prepareGeometryChange()
self.element.clear()
self.element.update(dialog.element)
self.resize_handle.setPos(
float(self.element.get("width", 20)),
float(self.element.get("height", 20)),
)
self.update()
elif chosen is delete and self.scene() is not None:
self.scene().removeItem(self)
self.element["_deleted"] = True
event.accept()
class PortHandle(QGraphicsEllipseItem):
def __init__(self, port: Port, direction: str, position: QPointF) -> None:
super().__init__(-5, -5, 10, 10)
self.port, self.direction = port, direction
self.setPos(position)
self.setBrush(QColor("#16a34a" if direction == "input" else "#dc2626"))
self.setPen(QPen(QColor("#ffffff"), 1.5))
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
self.setZValue(10)
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
value = QPointF(
min(bounds.right(), max(bounds.left(), _snap(value.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(value.y()))),
)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()}
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
self.setPos(
min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))),
min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))),
)
class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(f"Icon Editor — {component.name}")
self.resize(850, 600)
self.icon = Icon.from_dict(component.icon.to_dict())
self.inputs = deepcopy(component.inputs)
self.outputs = deepcopy(component.outputs)
layout = QVBoxLayout(self)
toolbar = QHBoxLayout()
toolbar.addWidget(QLabel("Add:"))
for kind in ("rectangle", "circle", "ellipse", "line", "triangle", "text"):
button = QToolButton()
button.setText(kind.title())
button.clicked.connect(lambda _checked=False, value=kind: self.add_shape(value))
toolbar.addWidget(button)
toolbar.addStretch()
delete = QPushButton("Delete selected")
delete.clicked.connect(self.delete_selected)
toolbar.addWidget(delete)
layout.addLayout(toolbar)
self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self)
self.view = IconEditorView(self.scene)
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.view.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
layout.addWidget(self.view, 1)
layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors."))
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
self._add_ports(self.inputs, "input", 0.0)
self._add_ports(self.outputs, "output", self.icon.width)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.view.fitInView(self.scene.sceneRect().adjusted(-10, -10, 10, 10), Qt.AspectRatioMode.KeepAspectRatio)
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)
for index, port in enumerate(ports, 1):
saved = port.properties.get("iconPosition", {})
position = QPointF(float(saved.get("x", default_x)), float(saved.get("y", spacing * index)))
self.scene.addItem(PortHandle(port, direction, position))
def add_shape(self, kind: str) -> None:
count = len([item for item in self.scene.items() if isinstance(item, ShapeItem)])
x, y = 15 + (count * 5) % 40, 15 + (count * 4) % 25
element = {"type": kind, "x": x, "y": y, "width": 55, "height": 35, "fill": "#dbeafe", "stroke": "#303030", "lineWidth": 1.5, "lineStyle": "solid"}
if kind == "circle":
element["width"] = element["height"] = 35
if kind == "line":
element["fill"] = "none"
if kind == "rectangle":
element["cornerRadius"] = 0
if kind == "text":
text, accepted = QInputDialog.getText(self, "Add Text", "Text:", text="Text")
if not accepted:
return
element.update({"text": text, "fontSize": 12, "color": "#202020", "fill": "none", "lineStyle": "none"})
self.icon.elements.append(element)
item = ShapeItem(element)
self.scene.addItem(item)
item.setSelected(True)
def delete_selected(self) -> None:
for item in self.scene.selectedItems():
if isinstance(item, ShapeItem):
self.scene.removeItem(item)
item.element["_deleted"] = True
def accept(self) -> None:
self.icon.elements = [element for element in self.icon.elements if not element.pop("_deleted", False)]
super().accept()

View File

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

View File

@@ -1,56 +0,0 @@
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

@@ -1,560 +0,0 @@
import json
from copy import deepcopy
from pathlib import Path
from PySide6.QtCore import QSettings, QSize, Qt, Slot
from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox, QToolBar
from bedit.component_options_dialog import ComponentOptionsDialog
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Port
from bedit.document.serializer import JsonDocumentSerializer
from bedit.item_options_dialog import ItemOptionsDialog
from bedit.library.repository import LibraryRepository
from bedit.library.tree_model import (
COMPONENT_ID_ROLE,
COMPONENT_INSTANCE_ROLE,
ITEM_KIND_ROLE,
DocumentTreeModel,
LibraryTreeModel,
)
from bedit.settings_dialog import SettingsDialog
from bedit.port_options_dialog import PortOptionsDialog
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.ui.emptyPage.setStyleSheet("background-color: #9a9a9a;")
self.ui.emptyWorkspaceLabel.setStyleSheet(
"background: transparent; color: #202020;"
)
self._create_camera_toolbar()
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.setIconSize(QSize(28, 28))
self.ui.treeView.setStyleSheet("QTreeView::item { height: 32px; }")
self.ui.treeView.setHeaderHidden(True)
self.ui.treeView.setDragEnabled(True)
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
self.ui.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.ui.treeView.customContextMenuRequested.connect(
self.show_external_library_context_menu
)
self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
self.ui.documentTreeView.setModel(self.document_tree_model)
self.ui.documentTreeView.setIconSize(QSize(16, 16))
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.componentPortOptionsRequested.connect(self.show_component_port_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.hide()
self.ui.outputToolButton.hide()
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
def _create_camera_toolbar(self) -> None:
self.cameraToolbar = QToolBar("Camera", self)
self.cameraToolbar.setObjectName("cameraToolbar")
self.actionZoomIn = QAction("Zoom In", self)
self.actionZoomIn.setShortcut("Ctrl++")
self.actionZoomOut = QAction("Zoom Out", self)
self.actionZoomOut.setShortcut("Ctrl+-")
self.actionCenterView = QAction("Center", self)
self.actionCenterView.setShortcut("Ctrl+0")
self.cameraToolbar.addActions(
(self.actionZoomIn, self.actionZoomOut, self.actionCenterView)
)
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.cameraToolbar)
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.actionZoomIn.triggered.connect(self.ui.graphView.zoom_in)
self.actionZoomOut.triggered.connect(self.ui.graphView.zoom_out)
self.actionCenterView.triggered.connect(self.ui.graphView.center_workspace)
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.cameraToolbar,
):
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)
self.ui.pointerToolButton.setVisible(is_graph)
self.ui.inputToolButton.hide()
self.ui.outputToolButton.hide()
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("pointer")
self.ui.pointerToolButton.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.settingsChanged.connect(self.refresh_editor_settings)
dialog.exec()
def refresh_editor_settings(self) -> None:
scene = self.ui.graphView.scene()
if scene is not None:
scene.update()
self.ui.graphView.viewport().update()
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…")
ports_action = menu.addAction("Port 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 ports_action:
self.show_component_port_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(object)
def show_external_library_context_menu(self, position) -> None:
tree = self.ui.treeView
index = tree.indexAt(position)
component = index.data(COMPONENT_INSTANCE_ROLE)
if not isinstance(component, Component):
return
menu = QMenu(self)
ports_action = menu.addAction("Port Options…")
if menu.exec(tree.viewport().mapToGlobal(position)) is ports_action:
dialog = PortOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs)
component.inputs = dialog.inputs
component.outputs = dialog.outputs
library = next(
(
library
for library in self.libraries.libraries
if any(item is component for item in library.document.all_components())
),
None,
)
try:
if library is not None:
library.document.validate()
JsonDocumentSerializer.save(
library.document, Path(library.source_path)
)
except (OSError, ValueError) as error:
component.inputs, component.outputs = old_inputs, old_outputs
QMessageBox.warning(self, "Cannot change library ports", str(error))
self.library_tree_model.rebuild()
@Slot(str)
def show_component_port_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 = PortOptionsDialog(component, self)
if dialog.exec() != dialog.DialogCode.Accepted:
return
try:
self.document_controller.edit_component_ports(
component_id, dialog.inputs, dialog.outputs
)
except ValueError as error:
QMessageBox.warning(self, "Cannot change ports", str(error))
@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.edited_icon,
dialog.edited_inputs,
dialog.edited_outputs,
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()

Some files were not shown because too many files have changed in this diff Show More