Compare commits
40 Commits
713d094b08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d9cc7651 | |||
| 1d535c1f5b | |||
| 60a30d4ee3 | |||
| e88c58f095 | |||
| b1f453a6df | |||
| 8d37b2441a | |||
| a61c8e6003 | |||
| 4591e6b7b0 | |||
| cb9e03a6bf | |||
| 0bb2141673 | |||
| fcee2ac8b6 | |||
| 8e7d2d3efd | |||
| 0c93338e39 | |||
| 961a173a17 | |||
| dfff760cc1 | |||
| c8373c3423 | |||
| 4b565edb5c | |||
| feba2c9798 | |||
| 51d01ad208 | |||
| e85c507d7f | |||
| 756a621ab1 | |||
| 2128fb00b4 | |||
| a38d461a36 | |||
| 22e9a0386c | |||
| 89ac2d8ff8 | |||
| 66737e323b | |||
| b3aabae5e8 | |||
| a03c6d624e | |||
| 748bb08531 | |||
| 9df352f6b7 | |||
| 346a963acc | |||
| 5df9e53d58 | |||
| 51a1537c9c | |||
| a4daaa8798 | |||
| 54788583a4 | |||
| e5968e165e | |||
| c68e693359 | |||
| 13d3825f9b | |||
| 035cbdf53f | |||
| 09a9e3f12f |
23
.vscode/launch.json
vendored
23
.vscode/launch.json
vendored
@@ -5,7 +5,7 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Module",
|
||||
"name": "BEdit debug",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "bedit_gui",
|
||||
@@ -14,7 +14,26 @@
|
||||
"env": {
|
||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||
"QT_QPA_PLATFORM": "xcb"
|
||||
}
|
||||
},
|
||||
"args": [
|
||||
"-f", "${workspaceFolder}/untitled.bedit.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "BEsim debug",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "bedit_gui.simulation_application",
|
||||
"preLaunchTask": "Qt: Generate files",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||
"QT_QPA_PLATFORM": "xcb"
|
||||
},
|
||||
"args": [
|
||||
"-s", "run_bondgraph",
|
||||
"${workspaceFolder}/untitled.besim.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@@ -41,7 +41,7 @@
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
"problemMatcher": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
237
AGENTS.md
Normal file
237
AGENTS.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# BEdit Agent Guide
|
||||
|
||||
This file is the handoff context for coding agents working in this repository. Read it before making changes.
|
||||
|
||||
## Working agreement
|
||||
|
||||
- Make only the changes the user requested.
|
||||
- Keep completely out of unrelated code. Do not reformat, reorder, rename, clean up, or “improve” code that is outside the task.
|
||||
- Preserve existing user changes and assume a dirty worktree belongs to the user.
|
||||
- Inspect the relevant files before deciding on an implementation.
|
||||
- Prefer small, modular changes over growing `MainWindow`, a controller, or another file into a monolith.
|
||||
- When the user asks why something happens, diagnose and explain it without editing files unless they also ask for a fix.
|
||||
- Do not create commits unless explicitly requested.
|
||||
|
||||
## Code style
|
||||
|
||||
Follow the local style in the file being edited, with these user preferences taking priority:
|
||||
|
||||
- Prefer compact, readable one-line imports. Do not introduce parenthesized multiline imports.
|
||||
- Avoid spreading short function calls, conditions, and expressions across multiple lines.
|
||||
- Do not run broad formatters or import sorters.
|
||||
- Do not use a lint autofix over the project.
|
||||
- Keep classes and functions focused. Extract a controller, service, command, model, or reusable widget when a feature would otherwise make an existing file large.
|
||||
- Generated modules contain no handwritten application behavior.
|
||||
|
||||
The project uses Ruff, but import sorting is intentionally not enforced during scoped checks:
|
||||
|
||||
```sh
|
||||
.venv/bin/ruff check --ignore I001 path/to/changed_file.py
|
||||
```
|
||||
|
||||
## Project structure and dependency direction
|
||||
|
||||
Relevant GUI structure:
|
||||
|
||||
```text
|
||||
src/bedit_gui/
|
||||
├── application.py
|
||||
├── commands/
|
||||
├── controllers/
|
||||
├── documents/
|
||||
├── models.py
|
||||
├── services/
|
||||
├── ui/
|
||||
│ ├── forms/
|
||||
│ └── generated/
|
||||
├── resources/
|
||||
│ └── generated/
|
||||
├── utils/
|
||||
└── views/
|
||||
└── models/
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `application.py`: composition root. Create and connect the application, document, window, services, and controllers here.
|
||||
- `documents/document.py`: editable document facade. Owns the core model, path, main `QUndoStack`, modified state, and high-level operations.
|
||||
- `commands/`: `QUndoCommand` implementations. Persistent changes to the document model go through commands.
|
||||
- `controllers/`: connect actions and widgets to document/service operations. Keep workflow logic out of `MainWindow`.
|
||||
- `views/`: handwritten widget/window/graphics behavior.
|
||||
- `views/models/`: Qt item models used by views.
|
||||
- `services/`: non-visual functionality such as files, clipboard, logging, and settings.
|
||||
- `models.py`: GUI metadata persisted inside the core document, including icon, graph, and simulation databases.
|
||||
- `bedit_core`: domain model and serialization. It must never import from `bedit_gui`.
|
||||
|
||||
Preferred direction:
|
||||
|
||||
```text
|
||||
views/controllers
|
||||
↓
|
||||
documents/services
|
||||
↓
|
||||
commands
|
||||
↓
|
||||
bedit_core
|
||||
```
|
||||
|
||||
Avoid introducing imports from `bedit_gui` into `bedit_core` or circular dependencies between services and documents.
|
||||
|
||||
## Qt Designer and generated files
|
||||
|
||||
Raw forms are in:
|
||||
|
||||
```text
|
||||
src/bedit_gui/ui/forms/
|
||||
```
|
||||
|
||||
Generated Python is in:
|
||||
|
||||
```text
|
||||
src/bedit_gui/ui/generated/
|
||||
```
|
||||
|
||||
Never add handwritten behavior to generated UI modules. Change the `.ui` form and regenerate with:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/generate_qt_files.py
|
||||
```
|
||||
|
||||
The generation script also fixes the package-qualified resource import. Calling `pyside6-uic` directly without the script can produce a broken `resources_rc` import.
|
||||
|
||||
Raw resources and the QRC file live under `src/bedit_gui/resources/`. Generated resource Python lives under `src/bedit_gui/resources/generated/`.
|
||||
|
||||
## Undo and document changes
|
||||
|
||||
- The main application document owns the main undo stack.
|
||||
- The icon editor owns a separate local undo stack.
|
||||
- The `Document` facade should expose high-level methods that push commands. Controllers should normally call those methods rather than construct commands.
|
||||
- Commands mutate the model in `redo()`/`undo()` and emit the appropriate document signals.
|
||||
- Multi-object user operations should be one command or one undo macro.
|
||||
- Allocate stable IDs and final names before pushing a command so redo reproduces the same result.
|
||||
- Component names must be unique within their destination component dictionary. Conflicts use `_0`, `_1`, and so on.
|
||||
- Copy/paste must generate new component, port, parameter, connection, and icon-shape IDs and rewrite references.
|
||||
|
||||
## Document tree
|
||||
|
||||
`DocumentTreeModel` has two columns:
|
||||
|
||||
- Column 0: editable document/component name.
|
||||
- Column 1: rendered component icon.
|
||||
|
||||
Use `selectedRows(0)` for multi-selection; `selectedIndexes()` returns both columns. Filter selected descendants when an ancestor is also selected.
|
||||
|
||||
The tree uses extended row selection. Delete, cut, and copy may operate on multiple components. Paste targets either:
|
||||
|
||||
- the document root, or
|
||||
- the component dictionary of a selected graph component.
|
||||
|
||||
The controller listens to document model/icon signals and refreshes the tree/icon cache.
|
||||
|
||||
## Clipboard architecture
|
||||
|
||||
Clipboard support is intentionally extensible:
|
||||
|
||||
- `services/clipboard.py`: system `QClipboard` and MIME/JSON handling.
|
||||
- `services/component_clipboard.py`: component payload serialization and ID remapping.
|
||||
- `controllers/clipboard_controller.py`: focus-based action router and handlers.
|
||||
|
||||
`ClipboardHandler` is the base implementation for editor-specific routing. The document tree and graph editor have component handlers registered in `application.py`.
|
||||
|
||||
Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Component clipboard data uses the custom BEdit MIME type and JSON; never use pickle or live object references.
|
||||
|
||||
The graph handler copies/cuts/deletes selected component items and deletes selected connection items. Paste targets the displayed graph and places new components at the mouse position, or at the viewport center when the mouse is outside the canvas.
|
||||
|
||||
## Icon editor conventions
|
||||
|
||||
- Icons are GUI metadata stored in `document.metadata["icon_database"]`.
|
||||
- Shapes currently include rectangles, text, and lines.
|
||||
- Shape changes use the icon editor’s local undo stack.
|
||||
- Shapes are selectable, movable, resizable, pixel-snapped, and constrained to the icon scene.
|
||||
- Ports are separate 16×16 items: black for inputs and white for outputs. They can be moved but are not ordinary deletable/copyable shapes.
|
||||
- Colors are serialized as `#rrggbbaa`.
|
||||
- The icon scene is currently `(-64, -64, 128, 128)`.
|
||||
- The grid spacing is 8 scene pixels and is drawn only inside the scene rectangle.
|
||||
- Keep reusable widgets such as the RGBA color button independent of the icon editor.
|
||||
- Static icon previews belong in rendering utilities, not in the interactive editor scene.
|
||||
|
||||
## Graph editor conventions
|
||||
|
||||
- Graph GUI metadata is stored in `document.metadata["graph_database"]`. `GraphDatabase`, `Graph`, `GraphConnection`, and `GraphComponentLabel` live in `bedit_gui.models` and serialize through `to_data()`/`from_data()`.
|
||||
- Core graph topology and connections remain in `bedit_core.models`; positions, routed points, labels, and other presentation metadata belong in the GUI graph database.
|
||||
- Component positions are absolute scene positions. Connection metadata stores the full point list, including endpoints; only interior points are draggable corner items.
|
||||
- Component labels are visible by default, italic, and centered below the rendered icon. Their persisted position is relative to the icon’s bottom-center. Label visibility and completed label moves are undoable.
|
||||
- The graph editor has normal and connection modes. The toolbar actions are exclusive and Space toggles modes while focus is inside the editor.
|
||||
- Normal mode supports component and label dragging. Connection mode shows icon ports, prevents component/label movement, and uses two component clicks to choose a compatible port pair.
|
||||
- Signal connections require output-to-input. Signal outputs may fan out; signal inputs accept only one connection. A bond port accepts another connection only when its `multiplicity` is true. Bond domains must be compatible.
|
||||
- While choosing a connection, the first component is highlighted and a temporary dashed line follows the mouse. When several port pairs are possible, output-to-input choices are listed first.
|
||||
- Signal connections render with full arrows. Bond connections render with half arrows and a perpendicular causality tick. Connection endpoints are clipped to rendered icon bounds plus `CONNECTION_BOUNDING_BOX_SPACING`.
|
||||
- Components, connections, and connection points are separate graphics items. Connections are selectable/deletable; connection points are draggable and have their own delete context action.
|
||||
- Persistent canvas edits go through `Document` methods and graph-specific `QUndoCommand` classes. Incremental document signals must also update the editor’s cached `Graph`; otherwise rebuilding items during a mode switch can restore stale metadata.
|
||||
- `render_icon(..., render_ports=True)` is the single port-rendering path. Do not duplicate icon or port geometry in the graph editor.
|
||||
- Graph sizing and rendering constants, including `COMPONENT_LABEL_FONT_SIZE`, live near the top of `views/graph_editor_widget.py`.
|
||||
- Double-clicking a canvas component selects its document-tree row and opens its graph or equation editor. Canvas component context menus share the document-tree editing actions and add graph-only presentation actions such as Show Label.
|
||||
|
||||
## Actions and shortcut routing
|
||||
|
||||
- Put visible actions in Designer menus/toolbars.
|
||||
- An action that only exists as a child object may not have an active shortcut; attach it to the relevant widget or place it in a menu/toolbar.
|
||||
- Route application-wide Copy/Cut/Paste by focused widget through `ClipboardController`.
|
||||
- Scope destructive shortcuts to the relevant widget where appropriate.
|
||||
- Always guard the operation itself even when an action is disabled for presentation.
|
||||
|
||||
## Application settings
|
||||
|
||||
- `ApplicationSettings` is the typed `QSettings` facade for BEdit preferences. The current settings include log level, graph snap-to-grid size, and ordered library paths under `libraries/paths`.
|
||||
- The Settings dialog edits library paths locally until OK is accepted. It supports BEdit `.bedit.json`/`.json` and `.beb` files, directories, duplicate suppression, extended selection, and list-focused Delete-key removal.
|
||||
- Add settings behavior in the handwritten dialog/controller/service modules, never in generated UI Python.
|
||||
|
||||
## Simulator application
|
||||
|
||||
- `bedit_gui/simulation_application.py` is the composition root for the separate `bedit-sim` Qt application.
|
||||
- Keep compilation GUI-independent. Shared compile entry points belong in `bedit_simulation` and must not create or depend on a `QApplication` or window.
|
||||
- The simulator opens BEdit `.beb`/`.json` documents or serialized simulation `.bes`/`.json` files.
|
||||
- A component selector uses default simulation settings. A stored simulation-settings block already identifies its component; do not require both selectors.
|
||||
- `SimulationDatabase.active_simulation` is the persisted active settings ID selected when the Simulation Settings dialog is accepted.
|
||||
- Simulation files serialize `bedit_gui.simulation_models.SimulationRoot`. JSON uses the `root_type = "simulation_root"` discriminator; binary `.bes` files use their own BES header.
|
||||
- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects.
|
||||
- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process.
|
||||
- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller.
|
||||
- Keep compiled-executable launching, time-window progression, result loading, and cancellation inside `bedit_simulation`. GUI controllers may schedule backend calls and present state, but must not execute or manage simulation binaries themselves.
|
||||
- The BEdit Compile action performs bond-graph causality inference before Modelica compilation. Inference runs on a deep copy first, then inferred `causality`/`undesired` state is applied to the open document with an undoable command.
|
||||
- OpenModelica compilation runs `checkModel(...)` before `buildModel(...)`; the equation/variable summary flows through `ModelBuildResult.output` and is shown in the application log.
|
||||
|
||||
### Causality inference caveats
|
||||
|
||||
- `_CausalityEngine.inference()` clears every flattened bond’s causality to `NONE` before inference; it does not continue from saved causalities. It currently does not clear old `undesired` flags.
|
||||
- Preferred causalities are assigned before all junction constraints are resolved, and the engine has no backtracking to relax a preferred assignment. Some soft-preference conflicts therefore raise a junction error instead of marking a bond undesired.
|
||||
- `propagate_to_neighbor()` currently selects `connection.target` in both branches. When propagation starts at a target component, it should traverse to the source; account for this known bug when diagnosing inference behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
The old test suite and its VS Code/packaging references were deliberately removed. Do not recreate a test suite unless asked.
|
||||
|
||||
Use checks proportional to the change:
|
||||
|
||||
```sh
|
||||
.venv/bin/ruff check --ignore I001 path/to/changed_files.py
|
||||
```
|
||||
|
||||
For Qt smoke checks in a headless environment:
|
||||
|
||||
```sh
|
||||
QT_QPA_PLATFORM=minimal QT_QPA_PLATFORMTHEME= QT_STYLE_OVERRIDE=Fusion .venv/bin/python ...
|
||||
```
|
||||
|
||||
Prefer focused model, serialization, signal, command undo/redo, and offscreen rendering checks. Do not launch a GUI during verification unless explicitly requested or approved.
|
||||
|
||||
## Handoff expectations
|
||||
|
||||
At the end of a task, report:
|
||||
|
||||
- the outcome,
|
||||
- the files or subsystem changed,
|
||||
- relevant behavior and limitations,
|
||||
- checks that actually passed.
|
||||
|
||||
Do not claim tests passed when only a lint or smoke check was run.
|
||||
BIN
examples/BondGraphs.beb
Normal file
BIN
examples/BondGraphs.beb
Normal file
Binary file not shown.
25
icons/signal_base.json
Normal file
25
icons/signal_base.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"shapes": {
|
||||
"14409738-45af-40c5-9c9e-827218d54a95": {
|
||||
"layer": 0,
|
||||
"type": "rectangle",
|
||||
"pos": [
|
||||
-48,
|
||||
-48
|
||||
],
|
||||
"width": 96.0,
|
||||
"height": 96.0,
|
||||
"line_type": "solid",
|
||||
"line_thickness": 5.0,
|
||||
"corner_radius": 8.0,
|
||||
"line_color": "#00007fff",
|
||||
"fill_color": "#ebebebff"
|
||||
}
|
||||
},
|
||||
"port_positions": {
|
||||
"d5681d1d-70ee-4623-a41a-5a572e46d42c": [
|
||||
-8,
|
||||
-8
|
||||
]
|
||||
}
|
||||
}
|
||||
79
icons/signal_cosine_source.json
Normal file
79
icons/signal_cosine_source.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"shapes": {
|
||||
"9ed4189a-123b-431f-a6bc-bbf169487703": {
|
||||
"layer": 0,
|
||||
"type": "rectangle",
|
||||
"pos": [-48, -48],
|
||||
"width": 96.0,
|
||||
"height": 96.0,
|
||||
"line_type": "solid",
|
||||
"line_thickness": 5.0,
|
||||
"corner_radius": 8.0,
|
||||
"line_color": "#00007fff",
|
||||
"fill_color": "#ebebebff"
|
||||
},
|
||||
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
|
||||
"layer": 4,
|
||||
"type": "line",
|
||||
"pos": [-32, -32],
|
||||
"end": [-32, 32],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
},
|
||||
"2e623060-349e-4a48-97b0-a81921df5870": {
|
||||
"layer": 2,
|
||||
"type": "line",
|
||||
"pos": [-32, 32],
|
||||
"end": [32, 32],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
},
|
||||
"2c787197-3228-4f20-a57d-6fca410151b9": {
|
||||
"layer": 5, "type": "line", "pos": [-28, -16], "end": [-24, -15], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"53e49b2b-f3d6-4aa5-bd56-7ca7fbf68bb4": {
|
||||
"layer": 5, "type": "line", "pos": [-24, -15], "end": [-20, -11], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"7df5a7ce-b914-459e-93f6-f43450623baf": {
|
||||
"layer": 5, "type": "line", "pos": [-20, -11], "end": [-16, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"dd89bdfb-f10f-467a-aa90-a534e1f30dd8": {
|
||||
"layer": 5, "type": "line", "pos": [-16, -7], "end": [-12, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"df9439f1-588f-46d8-873e-ecc59f5b8e36": {
|
||||
"layer": 5, "type": "line", "pos": [-12, 0], "end": [-8, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"4984ed23-a303-494f-947d-a34964220298": {
|
||||
"layer": 5, "type": "line", "pos": [-8, 7], "end": [-4, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"af40c4f7-d33b-426e-b130-ce78184a350f": {
|
||||
"layer": 5, "type": "line", "pos": [-4, 13], "end": [0, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"63c0188e-4f24-4ec7-b8d6-6258ab13c226": {
|
||||
"layer": 5, "type": "line", "pos": [0, 16], "end": [4, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"c71d5e3b-a917-4dc8-8411-2046a54e2134": {
|
||||
"layer": 5, "type": "line", "pos": [4, 13], "end": [8, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"51e128e6-c63f-4437-b56f-d20c31d195e2": {
|
||||
"layer": 5, "type": "line", "pos": [8, 7], "end": [12, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"f135f861-3e98-4bb1-ab43-e51fa0eec8db": {
|
||||
"layer": 5, "type": "line", "pos": [12, 0], "end": [16, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"422ce3eb-8d0f-4b05-bb54-126583521240": {
|
||||
"layer": 5, "type": "line", "pos": [16, -7], "end": [20, -11], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"c2c60fc4-fd4c-4cfc-a90a-dd729fe551b0": {
|
||||
"layer": 5, "type": "line", "pos": [20, -11], "end": [24, -15], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"0852263b-237a-4a32-a56d-91945864e01b": {
|
||||
"layer": 5, "type": "line", "pos": [24, -15], "end": [28, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
}
|
||||
},
|
||||
"port_positions": {
|
||||
"37eb6e52-6e89-4e09-8923-43c6230f5486": [-8, -8]
|
||||
}
|
||||
}
|
||||
97
icons/signal_sine_source.json
Normal file
97
icons/signal_sine_source.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"shapes": {
|
||||
"9ed4189a-123b-431f-a6bc-bbf169487703": {
|
||||
"layer": 0,
|
||||
"type": "rectangle",
|
||||
"pos": [
|
||||
-48,
|
||||
-48
|
||||
],
|
||||
"width": 96.0,
|
||||
"height": 96.0,
|
||||
"line_type": "solid",
|
||||
"line_thickness": 5.0,
|
||||
"corner_radius": 8.0,
|
||||
"line_color": "#00007fff",
|
||||
"fill_color": "#ebebebff"
|
||||
},
|
||||
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
|
||||
"layer": 4,
|
||||
"type": "line",
|
||||
"pos": [
|
||||
-32,
|
||||
-32
|
||||
],
|
||||
"end": [
|
||||
-32,
|
||||
32
|
||||
],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
},
|
||||
"2e623060-349e-4a48-97b0-a81921df5870": {
|
||||
"layer": 2,
|
||||
"type": "line",
|
||||
"pos": [
|
||||
-32,
|
||||
32
|
||||
],
|
||||
"end": [
|
||||
32,
|
||||
32
|
||||
],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
},
|
||||
"5cfcb42c-5cc3-48aa-ad92-7d19807c95a0": {
|
||||
"layer": 5, "type": "line", "pos": [-28, 0], "end": [-24, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"78f194ed-dae0-470a-a354-f7c4c3e54c81": {
|
||||
"layer": 5, "type": "line", "pos": [-24, -7], "end": [-20, -13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"cfa29bc9-5fac-4f00-bb02-52498fd14f92": {
|
||||
"layer": 5, "type": "line", "pos": [-20, -13], "end": [-16, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"8f852157-0395-4028-a9ce-f7bd596f2976": {
|
||||
"layer": 5, "type": "line", "pos": [-16, -16], "end": [-12, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"6f535277-a28e-4abc-82d4-4cf515a80357": {
|
||||
"layer": 5, "type": "line", "pos": [-12, -16], "end": [-8, -13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"a40bba3a-d9fd-4a7c-b409-2222ce69c558": {
|
||||
"layer": 5, "type": "line", "pos": [-8, -13], "end": [-4, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"2720ddd9-0332-46b6-bb79-dd90aa0892bd": {
|
||||
"layer": 5, "type": "line", "pos": [-4, -7], "end": [0, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"e420ce57-f79d-4941-8f5a-8207e15f0306": {
|
||||
"layer": 5, "type": "line", "pos": [0, 0], "end": [4, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"f53f0310-7e14-4a87-aeb3-e079e2a8460e": {
|
||||
"layer": 5, "type": "line", "pos": [4, 7], "end": [8, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"915cce34-b991-4119-88b5-c2dd41eec73c": {
|
||||
"layer": 5, "type": "line", "pos": [8, 13], "end": [12, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"56d62c0f-4156-49eb-b048-b3b42304d6b0": {
|
||||
"layer": 5, "type": "line", "pos": [12, 16], "end": [16, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"de990624-dbb2-434c-bbdf-b0ea9caa34bd": {
|
||||
"layer": 5, "type": "line", "pos": [16, 16], "end": [20, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"b48fb633-b679-433f-8bee-f7c9549235aa": {
|
||||
"layer": 5, "type": "line", "pos": [20, 13], "end": [24, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
},
|
||||
"17f88457-c04d-4c08-90f6-f6ec5166c11c": {
|
||||
"layer": 5, "type": "line", "pos": [24, 7], "end": [28, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
|
||||
}
|
||||
},
|
||||
"port_positions": {
|
||||
"37eb6e52-6e89-4e09-8923-43c6230f5486": [
|
||||
-8,
|
||||
-8
|
||||
]
|
||||
}
|
||||
}
|
||||
55
icons/signal_source_base.json
Normal file
55
icons/signal_source_base.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"shapes": {
|
||||
"9ed4189a-123b-431f-a6bc-bbf169487703": {
|
||||
"layer": 0,
|
||||
"type": "rectangle",
|
||||
"pos": [
|
||||
-48,
|
||||
-48
|
||||
],
|
||||
"width": 96.0,
|
||||
"height": 96.0,
|
||||
"line_type": "solid",
|
||||
"line_thickness": 5.0,
|
||||
"corner_radius": 8.0,
|
||||
"line_color": "#00007fff",
|
||||
"fill_color": "#ebebebff"
|
||||
},
|
||||
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
|
||||
"layer": 4,
|
||||
"type": "line",
|
||||
"pos": [
|
||||
-32,
|
||||
-32
|
||||
],
|
||||
"end": [
|
||||
-32,
|
||||
32
|
||||
],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
},
|
||||
"2e623060-349e-4a48-97b0-a81921df5870": {
|
||||
"layer": 2,
|
||||
"type": "line",
|
||||
"pos": [
|
||||
-32,
|
||||
32
|
||||
],
|
||||
"end": [
|
||||
32,
|
||||
32
|
||||
],
|
||||
"line_type": "solid",
|
||||
"line_thickness": 3.0,
|
||||
"line_color": "#000000ff"
|
||||
}
|
||||
},
|
||||
"port_positions": {
|
||||
"37eb6e52-6e89-4e09-8923-43c6230f5486": [
|
||||
-8,
|
||||
-8
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
lib/bondgraph.beb
Normal file
BIN
lib/bondgraph.beb
Normal file
Binary file not shown.
BIN
lib/signal.beb
Normal file
BIN
lib/signal.beb
Normal file
Binary file not shown.
BIN
lib/signal_sources.beb
Normal file
BIN
lib/signal_sources.beb
Normal file
Binary file not shown.
@@ -18,6 +18,7 @@ dependencies = [
|
||||
bedit-graphviz = "bedit_util.graphviz:main"
|
||||
bedit-simulate = "bedit_util.simulate:main"
|
||||
bedit = "bedit_gui.application:main"
|
||||
besim = "bedit_gui.simulation_application:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
@@ -83,6 +83,7 @@ class _CausalityEngine():
|
||||
def clear_causalities(self) -> None:
|
||||
for bond in self._network.bonds:
|
||||
bond.connection.causality = BondCausality.NONE
|
||||
bond.connection.undesired = False
|
||||
|
||||
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
|
||||
attached_connections = self._network.bonds_for(port)
|
||||
@@ -106,7 +107,7 @@ class _CausalityEngine():
|
||||
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
|
||||
# Get neighor
|
||||
is_source = (component == connection.source.component)
|
||||
neighbor = connection.target if is_source else connection.target
|
||||
neighbor = connection.target if is_source else connection.source
|
||||
# Direct the evaluation based on what type of port it is
|
||||
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
|
||||
self.evaluate_junction_constraints(neighbor)
|
||||
|
||||
@@ -1,42 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, TextClipboardHandler
|
||||
from bedit_gui.controllers.document_controller import DocumentController
|
||||
from bedit_gui.controllers.log_controller import LogController
|
||||
from bedit_gui.controllers.library_controller import LibraryController
|
||||
from bedit_gui.controllers.settings_controller import SettingsController
|
||||
from bedit_gui.controllers.simulation_settings_controller import SimulationSettingsController
|
||||
from bedit_gui.controllers.simulation_controller import SimulationController
|
||||
from bedit_gui.controllers.undo_controller import UndoController
|
||||
from bedit_gui.controllers.view_menu_controller import ViewMenuController
|
||||
from bedit_gui.controllers.window_state_controller import WindowStateController
|
||||
from bedit_gui.controllers.document_tree_controller import DocumentTreeController
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.services.clipboard import ClipboardService
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.versions import BEDIT_VERSION
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(exit_on_error=False)
|
||||
|
||||
parser.add_argument(
|
||||
"-f", "--file", type=str, help="Path to a file to open", default=None, required=False
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"BEdit {BEDIT_VERSION}")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
args = parse_arguments()
|
||||
except argparse.ArgumentError as e:
|
||||
print(e.message)
|
||||
return 1
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
app.setOrganizationName("BEdit")
|
||||
app.setApplicationName("BEdit")
|
||||
app.setApplicationVersion(BEDIT_VERSION)
|
||||
|
||||
settings = ApplicationSettings()
|
||||
document = Document(app)
|
||||
window = MainWindow()
|
||||
window = MainWindow(settings.snap_to_grid_size)
|
||||
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}"))
|
||||
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
|
||||
|
||||
LogController(window, settings.log_level)
|
||||
DocumentController(document, window)
|
||||
SettingsController(window, settings)
|
||||
settings_controller = SettingsController(window, settings)
|
||||
SimulationSettingsController(document, window)
|
||||
SimulationController(document, window)
|
||||
UndoController(document, window)
|
||||
ViewMenuController(window)
|
||||
DocumentTreeController(document, window)
|
||||
document_tree_controller = DocumentTreeController(document, window)
|
||||
library_controller = LibraryController(window, settings)
|
||||
settings_controller.library_paths_changed.connect(library_controller.reload)
|
||||
clipboard = ClipboardService(app)
|
||||
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard), GraphEditorClipboardHandler(document, window.graph_editor, clipboard)])
|
||||
|
||||
window_state_controller = WindowStateController(app, window)
|
||||
window_state_controller.restore()
|
||||
|
||||
document.new()
|
||||
if args.file:
|
||||
document.open(args.file)
|
||||
else:
|
||||
document.new()
|
||||
|
||||
window.showMaximized()
|
||||
|
||||
return app.exec()
|
||||
|
||||
44
src/bedit_gui/commands/causality_command.py
Normal file
44
src/bedit_gui/commands/causality_command.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import BondCausality, BondConnection, Component, ConnectionID, GraphImplementation
|
||||
|
||||
CausalityState = dict[ConnectionID, tuple[BondCausality, bool]]
|
||||
|
||||
|
||||
def causality_state(component: Component) -> CausalityState:
|
||||
state = {}
|
||||
if not isinstance(component.implementation, GraphImplementation):
|
||||
return state
|
||||
for connection_id, connection in component.implementation.graph.connections.items():
|
||||
if isinstance(connection, BondConnection):
|
||||
state[connection_id] = (connection.causality, connection.undesired)
|
||||
for child in component.implementation.graph.components.values():
|
||||
state.update(causality_state(child))
|
||||
return state
|
||||
|
||||
|
||||
class ChangeCausalityCommand(QUndoCommand):
|
||||
def __init__(self, document: object, component: Component, inferred_component: Component) -> None:
|
||||
super().__init__("Infer causality")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.old_state = causality_state(component)
|
||||
self.new_state = causality_state(inferred_component)
|
||||
|
||||
def redo(self) -> None:
|
||||
self._apply(self.component, self.new_state)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self._apply(self.component, self.old_state)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
@classmethod
|
||||
def _apply(cls, component: Component, state: CausalityState) -> None:
|
||||
if not isinstance(component.implementation, GraphImplementation):
|
||||
return
|
||||
for connection_id, connection in component.implementation.graph.connections.items():
|
||||
if isinstance(connection, BondConnection) and connection_id in state:
|
||||
connection.causality, connection.undesired = state[connection_id]
|
||||
for child in component.implementation.graph.components.values():
|
||||
cls._apply(child, state)
|
||||
23
src/bedit_gui/commands/change_icon_command.py
Normal file
23
src/bedit_gui/commands/change_icon_command.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
from bedit_gui.models import Icon
|
||||
|
||||
|
||||
class ChangeIconCommand(QUndoCommand):
|
||||
def __init__(self, document: object, component_id: ComponentID, icon: Icon) -> None:
|
||||
super().__init__("Change icon")
|
||||
self.document = document
|
||||
self.component_id = component_id
|
||||
self.old_icon = document.stored_component_icon(component_id)
|
||||
self.new_icon = deepcopy(icon)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_component_icon(self.component_id, self.new_icon)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_component_icon(self.component_id, self.old_icon)
|
||||
178
src/bedit_gui/commands/component_command.py
Normal file
178
src/bedit_gui/commands/component_command.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface, PortID
|
||||
from bedit_gui.models import Icon, PortMetadata
|
||||
|
||||
|
||||
class AddEmptyGraphComponent(QUndoCommand):
|
||||
def __init__(self, document: object, parent: Component | dict[ComponentID, Component]) -> None:
|
||||
super().__init__("Add graph component")
|
||||
self.document = document
|
||||
if isinstance(parent, Component):
|
||||
if not isinstance(parent.implementation, GraphImplementation):
|
||||
raise TypeError("parent component must have a graph implementation")
|
||||
self.components = parent.implementation.graph.components
|
||||
else:
|
||||
self.components = parent
|
||||
self.component_id = ComponentID()
|
||||
self.component = Component(name="new_graph_component", interface=Interface(), parameters={}, implementation=GraphImplementation(Graph()))
|
||||
|
||||
def redo(self) -> None:
|
||||
self.components[self.component_id] = self.component
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
del self.components[self.component_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class AddEmptyEquationComponent(QUndoCommand):
|
||||
def __init__(self, document: object, parent: Component | dict[ComponentID, Component]) -> None:
|
||||
super().__init__("Add equation component")
|
||||
self.document = document
|
||||
if isinstance(parent, Component):
|
||||
if not isinstance(parent.implementation, GraphImplementation):
|
||||
raise TypeError("parent component must have a graph implementation")
|
||||
self.components = parent.implementation.graph.components
|
||||
else:
|
||||
self.components = parent
|
||||
self.component_id = ComponentID()
|
||||
self.component = Component(name="New Equation Component", interface=Interface(), parameters={}, implementation=EquationImplementation())
|
||||
|
||||
def redo(self) -> None:
|
||||
self.components[self.component_id] = self.component
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
del self.components[self.component_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class DeleteComponent(QUndoCommand):
|
||||
def __init__(self, document: object, component: Component) -> None:
|
||||
super().__init__("Delete component")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.components, self.component_id, self.parent_graph = self._find_component(document.model.root, component)
|
||||
self.component_index = list(self.components).index(self.component_id)
|
||||
port_ids = set(component.interface.ports)
|
||||
self.connections: list[tuple[int, ConnectionID, Connection]] = []
|
||||
if self.parent_graph is not None:
|
||||
for index, (connection_id, connection) in enumerate(self.parent_graph.connections.items()):
|
||||
if connection.source in port_ids or connection.target in port_ids:
|
||||
self.connections.append((index, connection_id, connection))
|
||||
self.icons = {}
|
||||
self.port_metadata = {}
|
||||
for component_id in self._component_ids(self.component_id, component):
|
||||
icon = document.stored_component_icon(component_id)
|
||||
if icon is not None:
|
||||
self.icons[component_id] = icon
|
||||
database = document._port_metadata_database(False)
|
||||
if database is not None:
|
||||
for port_id in self._port_ids(component):
|
||||
if port_id in database.ports:
|
||||
self.port_metadata[port_id] = deepcopy(database.ports[port_id])
|
||||
|
||||
def redo(self) -> None:
|
||||
if self.parent_graph is not None:
|
||||
for _, connection_id, _ in self.connections:
|
||||
self.parent_graph.connections.pop(connection_id, None)
|
||||
self.components.pop(self.component_id, None)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
for component_id in self.icons:
|
||||
self.document._set_component_icon(component_id, None)
|
||||
if self.port_metadata:
|
||||
database = self.document.port_metadata_database()
|
||||
for port_id in self.port_metadata:
|
||||
database.ports.pop(port_id, None)
|
||||
self.document._set_port_metadata_database(database)
|
||||
|
||||
def undo(self) -> None:
|
||||
self._restore_item(self.components, self.component_id, self.component, self.component_index)
|
||||
if self.parent_graph is not None:
|
||||
for index, connection_id, connection in self.connections:
|
||||
self._restore_item(self.parent_graph.connections, connection_id, connection, index)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
for component_id, icon in self.icons.items():
|
||||
self.document._set_component_icon(component_id, icon)
|
||||
if self.port_metadata:
|
||||
database = self.document.port_metadata_database()
|
||||
database.ports.update(deepcopy(self.port_metadata))
|
||||
self.document._set_port_metadata_database(database)
|
||||
|
||||
@classmethod
|
||||
def _find_component(cls, components: dict[ComponentID, Component], target: Component, parent_graph: Graph | None = None) -> tuple[dict[ComponentID, Component], ComponentID, Graph | None]:
|
||||
for component_id, component in components.items():
|
||||
if component is target:
|
||||
return components, component_id, parent_graph
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
try:
|
||||
return cls._find_component(component.implementation.graph.components, target, component.implementation.graph)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError("component is not part of this document")
|
||||
|
||||
@classmethod
|
||||
def _component_ids(cls, component_id: ComponentID, component: Component) -> list[ComponentID]:
|
||||
component_ids = [component_id]
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
for child_id, child in component.implementation.graph.components.items():
|
||||
component_ids.extend(cls._component_ids(child_id, child))
|
||||
return component_ids
|
||||
|
||||
@classmethod
|
||||
def _port_ids(cls, component: Component) -> list[PortID]:
|
||||
port_ids = list(component.interface.ports)
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
for child in component.implementation.graph.components.values():
|
||||
port_ids.extend(cls._port_ids(child))
|
||||
return port_ids
|
||||
|
||||
@staticmethod
|
||||
def _restore_item(items: dict, item_id: object, item: object, index: int) -> None:
|
||||
values = list(items.items())
|
||||
values.insert(index, (item_id, item))
|
||||
items.clear()
|
||||
items.update(values)
|
||||
|
||||
|
||||
class PasteComponents(QUndoCommand):
|
||||
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
super().__init__("Paste components")
|
||||
self.document = document
|
||||
self.target = target
|
||||
self.components = components
|
||||
self.icons = icons
|
||||
self.graph_id = graph_id
|
||||
self.positions = positions or {}
|
||||
self.port_metadata = deepcopy(port_metadata or {})
|
||||
|
||||
def redo(self) -> None:
|
||||
self.target.update(self.components)
|
||||
if self.graph_id is not None:
|
||||
for component_id, position in self.positions.items():
|
||||
self.document._set_graph_component_position(self.graph_id, component_id, position)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
for component_id, icon in self.icons.items():
|
||||
self.document._set_component_icon(component_id, icon)
|
||||
if self.port_metadata:
|
||||
database = self.document.port_metadata_database()
|
||||
database.ports.update(deepcopy(self.port_metadata))
|
||||
self.document._set_port_metadata_database(database)
|
||||
|
||||
def undo(self) -> None:
|
||||
for component_id in self.components:
|
||||
self.target.pop(component_id, None)
|
||||
if self.graph_id is not None:
|
||||
for component_id in self.positions:
|
||||
self.document._set_graph_component_position(self.graph_id, component_id, None)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
for component_id in self.icons:
|
||||
self.document._set_component_icon(component_id, None)
|
||||
if self.port_metadata:
|
||||
database = self.document.port_metadata_database()
|
||||
for port_id in self.port_metadata:
|
||||
database.ports.pop(port_id, None)
|
||||
self.document._set_port_metadata_database(database)
|
||||
53
src/bedit_gui/commands/equation_text_command.py
Normal file
53
src/bedit_gui/commands/equation_text_command.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, EquationImplementation
|
||||
|
||||
|
||||
class ChangeEquationTextCommand(QUndoCommand):
|
||||
COMMAND_ID = 1001
|
||||
|
||||
def __init__(self, document: object, component: Component, section: str, text: list[str], edit_id: int) -> None:
|
||||
super().__init__(self._command_text(section))
|
||||
implementation = component.implementation
|
||||
if not isinstance(implementation, EquationImplementation):
|
||||
raise TypeError("equation text can only be changed on an equation component")
|
||||
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.section = section
|
||||
self.old_text = list(getattr(implementation, section))
|
||||
self.new_text = list(text)
|
||||
self.edit_id = edit_id
|
||||
|
||||
def id(self) -> int:
|
||||
return self.COMMAND_ID
|
||||
|
||||
def mergeWith(self, other: QUndoCommand) -> bool:
|
||||
if not isinstance(other, ChangeEquationTextCommand):
|
||||
return False
|
||||
if other.component is not self.component or other.section != self.section or other.edit_id != self.edit_id:
|
||||
return False
|
||||
self.new_text = list(other.new_text)
|
||||
return True
|
||||
|
||||
def redo(self) -> None:
|
||||
self._set_text(self.new_text)
|
||||
|
||||
def undo(self) -> None:
|
||||
self._set_text(self.old_text)
|
||||
|
||||
def _set_text(self, text: list[str]) -> None:
|
||||
implementation = self.component.implementation
|
||||
assert isinstance(implementation, EquationImplementation)
|
||||
setattr(implementation, self.section, list(text))
|
||||
self.document.equation_text_changed.emit(self.component, self.section)
|
||||
|
||||
@staticmethod
|
||||
def _command_text(section: str) -> str:
|
||||
return {
|
||||
"declarations": "Edit declarations",
|
||||
"initial_equations": "Edit initial equations",
|
||||
"equations": "Edit equations",
|
||||
}[section]
|
||||
54
src/bedit_gui/commands/graph_connection_command.py
Normal file
54
src/bedit_gui/commands/graph_connection_command.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, Connection, ConnectionID, GraphImplementation
|
||||
|
||||
|
||||
class AddGraphConnectionCommand(QUndoCommand):
|
||||
def __init__(self, document: object, graph_component: Component, connection: Connection) -> None:
|
||||
super().__init__("Add connection")
|
||||
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||
raise TypeError("component must have a graph implementation")
|
||||
self.document = document
|
||||
self.graph = graph_component.implementation.graph
|
||||
self.connection_id = ConnectionID()
|
||||
self.connection = connection
|
||||
|
||||
def redo(self) -> None:
|
||||
self.graph.connections[self.connection_id] = self.connection
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.graph.connections.pop(self.connection_id, None)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class DeleteGraphConnectionCommand(QUndoCommand):
|
||||
def __init__(self, document: object, graph_component: Component, connection_id: ConnectionID) -> None:
|
||||
super().__init__("Delete connection")
|
||||
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||
raise TypeError("component must have a graph implementation")
|
||||
self.document = document
|
||||
self.graph_id = document.component_id(graph_component)
|
||||
self.connections = graph_component.implementation.graph.connections
|
||||
self.connection_id = connection_id
|
||||
self.connection = self.connections[connection_id]
|
||||
self.index = list(self.connections).index(connection_id)
|
||||
database = document._graph_database(False)
|
||||
graph = database.graphs.get(self.graph_id) if database is not None else None
|
||||
self.visual_connection = deepcopy(graph.connections.get(connection_id)) if graph is not None else None
|
||||
|
||||
def redo(self) -> None:
|
||||
self.connections.pop(self.connection_id, None)
|
||||
self.document._set_graph_connection_points(self.graph_id, self.connection_id, None)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
items = list(self.connections.items())
|
||||
items.insert(self.index, (self.connection_id, self.connection))
|
||||
self.connections.clear()
|
||||
self.connections.update(items)
|
||||
if self.visual_connection is not None:
|
||||
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.visual_connection.points)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal file
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import ComponentID, ConnectionID
|
||||
|
||||
|
||||
class ChangeGraphConnectionPointsCommand(QUndoCommand):
|
||||
def __init__(self, document: object, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||
super().__init__(text)
|
||||
self.document = document
|
||||
self.graph_id = graph_id
|
||||
self.connection_id = connection_id
|
||||
database = document._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
connection = graph.connections.get(connection_id) if graph is not None else None
|
||||
self.old_points = deepcopy(connection.points) if connection is not None else None
|
||||
self.new_points = deepcopy(points)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.new_points)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.old_points)
|
||||
24
src/bedit_gui/commands/graph_label_command.py
Normal file
24
src/bedit_gui/commands/graph_label_command.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
from bedit_gui.models import GraphComponentLabel
|
||||
|
||||
|
||||
class ChangeGraphComponentLabelCommand(QUndoCommand):
|
||||
def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel, text: str) -> None:
|
||||
super().__init__(text)
|
||||
self.document = document
|
||||
self.graph_id = graph_id
|
||||
self.component_id = component_id
|
||||
database = document._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
self.old_label = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else None
|
||||
self.new_label = deepcopy(label)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_graph_component_label(self.graph_id, self.component_id, self.new_label)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_graph_component_label(self.graph_id, self.component_id, self.old_label)
|
||||
21
src/bedit_gui/commands/graph_position_command.py
Normal file
21
src/bedit_gui/commands/graph_position_command.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
|
||||
|
||||
class MoveGraphComponentCommand(QUndoCommand):
|
||||
def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int]) -> None:
|
||||
super().__init__("Move graph component")
|
||||
self.document = document
|
||||
self.graph_id = graph_id
|
||||
self.component_id = component_id
|
||||
database = document._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
self.old_position = graph.component_positions.get(component_id) if graph is not None else None
|
||||
self.new_position = position
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_graph_component_position(self.graph_id, self.component_id, self.new_position)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_graph_component_position(self.graph_id, self.component_id, self.old_position)
|
||||
19
src/bedit_gui/commands/port_metadata_command.py
Normal file
19
src/bedit_gui/commands/port_metadata_command.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_gui.models import PortMetadataDatabase
|
||||
|
||||
|
||||
class ChangePortMetadataDatabaseCommand(QUndoCommand):
|
||||
def __init__(self, document: object, database: PortMetadataDatabase) -> None:
|
||||
super().__init__("Change port metadata")
|
||||
self.document = document
|
||||
self.old_database = document.stored_port_metadata_database()
|
||||
self.new_database = deepcopy(database)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_port_metadata_database(self.new_database)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_port_metadata_database(self.old_database)
|
||||
21
src/bedit_gui/commands/simulation_database_command.py
Normal file
21
src/bedit_gui/commands/simulation_database_command.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_gui.models import SimulationDatabase
|
||||
|
||||
|
||||
class ChangeSimulationDatabaseCommand(QUndoCommand):
|
||||
def __init__(self, document: object, database: SimulationDatabase) -> None:
|
||||
super().__init__("Edit simulation settings")
|
||||
self.document = document
|
||||
self.old_database = document.stored_simulation_database()
|
||||
self.new_database = deepcopy(database)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document._set_simulation_database(self.new_database)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document._set_simulation_database(self.old_database)
|
||||
116
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
116
src/bedit_gui/commands/simulation_plot_commands.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings, SimulationPlotTab, SimulationRoot
|
||||
|
||||
ChangedCallback = Callable[[int], None]
|
||||
|
||||
|
||||
class AddSimulationPlotTabCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, tab: SimulationPlotTab, index: int, changed: ChangedCallback) -> None:
|
||||
super().__init__("Add plot")
|
||||
self.root = root
|
||||
self.tab = tab
|
||||
self.index = index
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs.insert(self.index, self.tab)
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs.pop(self.index)
|
||||
self.changed(max(0, self.index - 1))
|
||||
|
||||
|
||||
class RemoveSimulationPlotTabCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, changed: ChangedCallback) -> None:
|
||||
super().__init__("Remove plot")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.tab = root.plot_tabs[index]
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs.pop(self.index)
|
||||
self.changed(min(self.index, len(self.root.plot_tabs) - 1))
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs.insert(self.index, self.tab)
|
||||
self.changed(self.index)
|
||||
|
||||
|
||||
class RenameSimulationPlotTabCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, name: str, changed: ChangedCallback) -> None:
|
||||
super().__init__("Rename plot")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_name = root.plot_tabs[index].name
|
||||
self.name = name
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].name = self.name
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].name = self.old_name
|
||||
self.changed(self.index)
|
||||
|
||||
|
||||
class ChangeSimulationPlotSignalsCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, signals: list[str], changed: ChangedCallback) -> None:
|
||||
super().__init__("Change plotted signals")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_signals = list(root.plot_tabs[index].signals)
|
||||
self.signals = list(signals)
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].signals = list(self.signals)
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].signals = list(self.old_signals)
|
||||
self.changed(self.index)
|
||||
|
||||
|
||||
class ChangeSimulationPlotXAxisCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, x_axis: str | None, changed: ChangedCallback) -> None:
|
||||
super().__init__("Change plot x axis")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_x_axis = root.plot_tabs[index].x_axis
|
||||
self.x_axis = x_axis
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].x_axis = self.x_axis
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].x_axis = self.old_x_axis
|
||||
self.changed(self.index)
|
||||
|
||||
|
||||
class ChangeSimulationPlotSettingsCommand(QUndoCommand):
|
||||
def __init__(self, root: SimulationRoot, index: int, settings: SimulationPlotSettings, changed: ChangedCallback) -> None:
|
||||
super().__init__("Change plot settings")
|
||||
self.root = root
|
||||
self.index = index
|
||||
self.old_settings = deepcopy(root.plot_tabs[index].settings)
|
||||
self.settings = deepcopy(settings)
|
||||
self.changed = changed
|
||||
|
||||
def redo(self) -> None:
|
||||
self.root.plot_tabs[self.index].settings = deepcopy(self.settings)
|
||||
self.changed(self.index)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.root.plot_tabs[self.index].settings = deepcopy(self.old_settings)
|
||||
self.changed(self.index)
|
||||
318
src/bedit_gui/controllers/clipboard_controller.py
Normal file
318
src/bedit_gui/controllers/clipboard_controller.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QTimer, Signal
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QTextEdit, QTreeView, QWidget
|
||||
|
||||
from bedit_core.models import Component, GraphImplementation
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services.clipboard import ClipboardService
|
||||
from bedit_gui.services.component_clipboard import export_components, import_components
|
||||
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
|
||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||
|
||||
|
||||
class ClipboardHandler(QObject):
|
||||
"""Base implementation for adding clipboard support to another editor."""
|
||||
|
||||
availability_changed = Signal()
|
||||
|
||||
def owns_focus(self, _widget: QWidget) -> bool:
|
||||
return False
|
||||
|
||||
def can_copy(self) -> bool:
|
||||
return False
|
||||
|
||||
def can_cut(self) -> bool:
|
||||
return False
|
||||
|
||||
def can_paste(self) -> bool:
|
||||
return False
|
||||
|
||||
def can_delete(self) -> bool:
|
||||
return False
|
||||
|
||||
def copy(self) -> None:
|
||||
pass
|
||||
|
||||
def cut(self) -> None:
|
||||
pass
|
||||
|
||||
def paste(self) -> None:
|
||||
pass
|
||||
|
||||
def delete(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TextClipboardHandler(ClipboardHandler):
|
||||
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.clipboard = clipboard
|
||||
|
||||
def owns_focus(self, widget: QWidget) -> bool:
|
||||
return isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit))
|
||||
|
||||
def can_copy(self) -> bool:
|
||||
return self._has_selection()
|
||||
|
||||
def can_cut(self) -> bool:
|
||||
widget = self._widget()
|
||||
return widget is not None and not widget.isReadOnly() and self._has_selection()
|
||||
|
||||
def can_paste(self) -> bool:
|
||||
widget = self._widget()
|
||||
return widget is not None and not widget.isReadOnly() and self.clipboard.has_text()
|
||||
|
||||
def copy(self) -> None:
|
||||
widget = self._widget()
|
||||
if widget is not None:
|
||||
widget.copy()
|
||||
|
||||
def cut(self) -> None:
|
||||
widget = self._widget()
|
||||
if widget is not None and not widget.isReadOnly():
|
||||
widget.cut()
|
||||
|
||||
def paste(self) -> None:
|
||||
widget = self._widget()
|
||||
if widget is not None and not widget.isReadOnly():
|
||||
widget.paste()
|
||||
|
||||
def _has_selection(self) -> bool:
|
||||
widget = self._widget()
|
||||
if isinstance(widget, QLineEdit):
|
||||
return widget.hasSelectedText()
|
||||
return widget.textCursor().hasSelection() if widget is not None else False
|
||||
|
||||
@staticmethod
|
||||
def _widget() -> QLineEdit | QTextEdit | QPlainTextEdit | None:
|
||||
widget = QApplication.focusWidget()
|
||||
return widget if isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit)) else None
|
||||
|
||||
|
||||
class DocumentTreeClipboardHandler(ClipboardHandler):
|
||||
def __init__(self, document: Document, tree: QTreeView, model: DocumentTreeModel, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.document = document
|
||||
self.tree = tree
|
||||
self.model = model
|
||||
self.clipboard = clipboard
|
||||
self.tree.selectionModel().selectionChanged.connect(self.availability_changed)
|
||||
self.tree.selectionModel().currentChanged.connect(self.availability_changed)
|
||||
|
||||
def owns_focus(self, widget: QWidget) -> bool:
|
||||
return widget is self.tree or self.tree.isAncestorOf(widget)
|
||||
|
||||
def can_copy(self) -> bool:
|
||||
return bool(self._selected_components())
|
||||
|
||||
def can_cut(self) -> bool:
|
||||
return self.can_copy()
|
||||
|
||||
def can_paste(self) -> bool:
|
||||
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
|
||||
|
||||
def can_delete(self) -> bool:
|
||||
return bool(self._selected_components())
|
||||
|
||||
def copy(self) -> None:
|
||||
components = self._selected_components()
|
||||
if components:
|
||||
self._write_components(components)
|
||||
|
||||
def cut(self) -> None:
|
||||
components = self._selected_components()
|
||||
if components:
|
||||
self._write_components(components)
|
||||
self.document.delete_components(components)
|
||||
|
||||
def paste(self) -> None:
|
||||
target = self._target()
|
||||
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
|
||||
if target is None or payload is None:
|
||||
return
|
||||
try:
|
||||
components, icons, port_metadata = import_components(payload)
|
||||
except (TypeError, ValueError) as exc:
|
||||
QMessageBox.critical(self.tree, "Could not paste components", str(exc))
|
||||
return
|
||||
self.document.paste_components(target, components, icons, port_metadata)
|
||||
|
||||
def delete(self) -> None:
|
||||
self.document.delete_components(self._selected_components())
|
||||
|
||||
def _write_components(self, components: list[Component]) -> None:
|
||||
payload = export_components(self.document, components)
|
||||
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
|
||||
|
||||
def _selected_components(self) -> list[Component]:
|
||||
indexes = self.tree.selectionModel().selectedRows(0)
|
||||
selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
|
||||
components: list[Component] = []
|
||||
for index in indexes:
|
||||
component = self.model.value(index)
|
||||
if not isinstance(component, Component):
|
||||
continue
|
||||
parent = index.parent()
|
||||
nested = False
|
||||
while parent.isValid():
|
||||
value = self.model.value(parent)
|
||||
if isinstance(value, Component) and id(value) in selected:
|
||||
nested = True
|
||||
break
|
||||
parent = parent.parent()
|
||||
if not nested:
|
||||
components.append(component)
|
||||
return components
|
||||
|
||||
def _target(self) -> dict | None:
|
||||
value = self.model.value(self.tree.currentIndex())
|
||||
if isinstance(value, CoreDocument):
|
||||
return value.root
|
||||
if isinstance(value, Component) and isinstance(value.implementation, GraphImplementation):
|
||||
return value.implementation.graph.components
|
||||
return None
|
||||
|
||||
|
||||
class GraphEditorClipboardHandler(ClipboardHandler):
|
||||
def __init__(self, document: Document, editor: GraphEditorWidget, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.document = document
|
||||
self.editor = editor
|
||||
self.clipboard = clipboard
|
||||
editor.scene.selectionChanged.connect(self.availability_changed)
|
||||
editor.component_drop_requested.connect(self.drop_components)
|
||||
|
||||
def owns_focus(self, widget: QWidget) -> bool:
|
||||
return widget is self.editor or self.editor.isAncestorOf(widget)
|
||||
|
||||
def can_copy(self) -> bool:
|
||||
return bool(self._selected_components())
|
||||
|
||||
def can_cut(self) -> bool:
|
||||
return self.can_copy()
|
||||
|
||||
def can_paste(self) -> bool:
|
||||
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
|
||||
|
||||
def can_delete(self) -> bool:
|
||||
return bool(self._selected_components() or self.editor.selected_connection_ids())
|
||||
|
||||
def copy(self) -> None:
|
||||
components = self._selected_components()
|
||||
if components:
|
||||
self._write_components(components)
|
||||
|
||||
def cut(self) -> None:
|
||||
components = self._selected_components()
|
||||
if components:
|
||||
self._write_components(components)
|
||||
self.document.delete_components(components)
|
||||
|
||||
def paste(self) -> None:
|
||||
graph_component = self.editor.component()
|
||||
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
|
||||
if graph_component is None or not isinstance(graph_component.implementation, GraphImplementation) or payload is None:
|
||||
return
|
||||
x, y = self.editor.paste_position()
|
||||
self._paste_payload(graph_component, payload, (x, y))
|
||||
|
||||
def drop_components(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
|
||||
self._paste_payload(graph_component, payload, position)
|
||||
|
||||
def _paste_payload(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
|
||||
try:
|
||||
components, icons, port_metadata = import_components(payload)
|
||||
except (TypeError, ValueError) as exc:
|
||||
QMessageBox.critical(self.editor, "Could not paste components", str(exc))
|
||||
return
|
||||
x, y = position
|
||||
spacing = self.editor.snap_to_grid_size * 4
|
||||
positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)}
|
||||
self.document.paste_graph_components(graph_component, components, icons, positions, port_metadata)
|
||||
|
||||
def delete(self) -> None:
|
||||
components = self._selected_components()
|
||||
connection_ids = self.editor.selected_connection_ids()
|
||||
if components:
|
||||
self.document.delete_components(components)
|
||||
graph_component = self.editor.component()
|
||||
if graph_component is not None and connection_ids:
|
||||
self.document.delete_graph_connections(graph_component, connection_ids)
|
||||
|
||||
def _write_components(self, components: list[Component]) -> None:
|
||||
payload = export_components(self.document, components)
|
||||
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
|
||||
|
||||
def _selected_components(self) -> list[Component]:
|
||||
target = self._target()
|
||||
if target is None:
|
||||
return []
|
||||
return [target[component_id] for component_id in self.editor.selected_component_ids() if component_id in target]
|
||||
|
||||
def _target(self) -> dict | None:
|
||||
component = self.editor.component()
|
||||
return component.implementation.graph.components if component is not None and isinstance(component.implementation, GraphImplementation) else None
|
||||
|
||||
|
||||
class ClipboardController(QObject):
|
||||
def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.clipboard = clipboard
|
||||
self.handlers = handlers
|
||||
window.ui.actionCopy.triggered.connect(self.copy)
|
||||
window.ui.actionCut.triggered.connect(self.cut)
|
||||
window.ui.actionPaste.triggered.connect(self.paste)
|
||||
window.ui.actionDelete.triggered.connect(self.delete)
|
||||
application = QApplication.instance()
|
||||
application.focusChanged.connect(self.update_actions)
|
||||
application.installEventFilter(self)
|
||||
clipboard.changed.connect(self.update_actions)
|
||||
for handler in handlers:
|
||||
handler.setParent(self)
|
||||
handler.availability_changed.connect(self.update_actions)
|
||||
self.update_actions()
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if event.type() in (QEvent.Type.KeyRelease, QEvent.Type.MouseButtonRelease):
|
||||
QTimer.singleShot(0, self.update_actions)
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def active_handler(self) -> ClipboardHandler | None:
|
||||
widget = QApplication.focusWidget()
|
||||
if widget is None:
|
||||
return None
|
||||
return next((handler for handler in self.handlers if handler.owns_focus(widget)), None)
|
||||
|
||||
def copy(self) -> None:
|
||||
handler = self.active_handler()
|
||||
if handler is not None and handler.can_copy():
|
||||
handler.copy()
|
||||
self.update_actions()
|
||||
|
||||
def cut(self) -> None:
|
||||
handler = self.active_handler()
|
||||
if handler is not None and handler.can_cut():
|
||||
handler.cut()
|
||||
self.update_actions()
|
||||
|
||||
def paste(self) -> None:
|
||||
handler = self.active_handler()
|
||||
if handler is not None and handler.can_paste():
|
||||
handler.paste()
|
||||
self.update_actions()
|
||||
|
||||
def delete(self) -> None:
|
||||
handler = self.active_handler()
|
||||
if handler is not None and handler.can_delete():
|
||||
handler.delete()
|
||||
self.update_actions()
|
||||
|
||||
def update_actions(self, *_args: object) -> None:
|
||||
handler = self.active_handler()
|
||||
self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
|
||||
self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
|
||||
self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
|
||||
self.window.ui.actionDelete.setEnabled(handler is not None and handler.can_delete())
|
||||
@@ -1,21 +1,28 @@
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject, QPoint, Qt
|
||||
from PySide6.QtWidgets import QDialog, QMenu
|
||||
from PySide6.QtCore import QEvent, QItemSelectionModel, QObject, QPoint, QSize, Qt
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
|
||||
|
||||
from bedit_core.models import Component, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.models import Graph, GraphComponentLabel, Icon, PortMetadata
|
||||
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
|
||||
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
|
||||
from bedit_gui.views.icon_editor_window import IconEditorWindow
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||
from bedit_gui.utils.icon import render_fitted_icon
|
||||
|
||||
ICON_SIZE = QSize(16, 16)
|
||||
|
||||
class InterfaceEditorLike(Protocol):
|
||||
def exec(self) -> int: ...
|
||||
def ports(self) -> dict[PortID, Port]: ...
|
||||
def port_metadata(self) -> dict[PortID, PortMetadata]: ...
|
||||
|
||||
class ParamEditorLike(Protocol):
|
||||
def exec(self) -> int: ...
|
||||
@@ -23,7 +30,7 @@ class ParamEditorLike(Protocol):
|
||||
|
||||
|
||||
InterfaceEditorFactory = Callable[
|
||||
[dict[PortID, Port], MainWindow],
|
||||
[dict[PortID, Port], dict[PortID, PortMetadata], MainWindow],
|
||||
InterfaceEditorLike,
|
||||
]
|
||||
|
||||
@@ -48,54 +55,214 @@ class DocumentTreeController(QObject):
|
||||
self.model = DocumentTreeModel()
|
||||
self.interface_editor_factory = interface_editor_factory
|
||||
self.param_editor_factory = param_editor_factory
|
||||
self._icon_editors: list[IconEditorWindow] = []
|
||||
self._components: dict[ComponentID, Component] = {}
|
||||
|
||||
window.ui.documentTree.setModel(self.model)
|
||||
window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed)
|
||||
window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text)
|
||||
window.equation_editor.port_metadata_change_requested.connect(self._change_equation_port_metadata)
|
||||
document.model_changed.connect(self._on_document_changed)
|
||||
document.icon_changed.connect(self._on_icon_changed)
|
||||
document.port_metadata_database_changed.connect(self._on_port_metadata_database_changed)
|
||||
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
|
||||
document.graph_component_label_changed.connect(self._on_graph_component_label_changed)
|
||||
document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed)
|
||||
document.equation_text_changed.connect(self._on_equation_text_changed)
|
||||
self.model.rename_document_requested.connect(self.document.rename)
|
||||
self.model.rename_component_requested.connect(self.document.rename_component)
|
||||
window.graph_editor.component_moves_requested.connect(self.document.move_graph_components)
|
||||
window.graph_editor.component_label_move_requested.connect(self.document.move_graph_component_label)
|
||||
window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu)
|
||||
window.graph_editor.component_open_requested.connect(self._open_graph_component)
|
||||
window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points)
|
||||
window.graph_editor.connection_add_requested.connect(self.document.add_graph_connection)
|
||||
window.graph_editor.connections_delete_requested.connect(self.document.delete_graph_connections)
|
||||
|
||||
# Add deselection with esc to this widget
|
||||
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
window.ui.documentTree.addAction(window.ui.actionEscape)
|
||||
window.ui.actionEscape.triggered.connect(self.deselect)
|
||||
|
||||
window.ui.documentTree.setHeaderHidden(True)
|
||||
window.ui.documentTree.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.CustomContextMenu
|
||||
)
|
||||
window.ui.documentTree.customContextMenuRequested.connect(
|
||||
self._show_context_menu
|
||||
)
|
||||
window.ui.documentTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
window.ui.documentTree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
window.ui.documentTree.setIconSize(QSize(24, 24))
|
||||
window.ui.documentTree.header().setStretchLastSection(False)
|
||||
window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
window.ui.documentTree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
window.ui.documentTree.setColumnWidth(1, 28)
|
||||
self._tree_viewport = window.ui.documentTree.viewport()
|
||||
self._tree_viewport.installEventFilter(self)
|
||||
|
||||
self._on_document_changed(document.model)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self._tree_viewport and event.type() in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease):
|
||||
assert isinstance(event, QMouseEvent)
|
||||
if event.button() == Qt.MouseButton.RightButton:
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self._show_context_menu(event.position().toPoint())
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _on_document_changed(self, model: CoreDocument) -> None:
|
||||
"""Rebuild the tree whenever New/Open replaces the core document."""
|
||||
displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component()
|
||||
self.model.set_document(model)
|
||||
self._components = {}
|
||||
self._collect_components(model.root)
|
||||
for component_id, component in self._components.items():
|
||||
icon = self.document.component_icon(component_id)
|
||||
self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
|
||||
self._show_component(displayed_component if any(component is displayed_component for component in self._components.values()) else None)
|
||||
|
||||
# Optional presentation behavior. Later, you could instead remember
|
||||
# expanded component IDs and restore only those nodes.
|
||||
self.window.ui.documentTree.expandAll()
|
||||
|
||||
def _selection_changed(self, *_args: object) -> None:
|
||||
indexes = self.window.ui.documentTree.selectionModel().selectedRows(0)
|
||||
component = self.model.value(indexes[0]) if len(indexes) == 1 else None
|
||||
self._show_component(component if isinstance(component, Component) else None)
|
||||
|
||||
def _show_component(self, component: Component | None) -> None:
|
||||
if component is not None and isinstance(component.implementation, EquationImplementation):
|
||||
port_metadata = {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports}
|
||||
self.window.equation_editor.set_component(component, port_metadata)
|
||||
self.window.equation_editor.show()
|
||||
else:
|
||||
self.window.equation_editor.set_component(None)
|
||||
self.window.equation_editor.hide()
|
||||
if component is not None and isinstance(component.implementation, GraphImplementation):
|
||||
graph = self.document.graph_database().graphs.get(self.document.component_id(component), Graph())
|
||||
icons = {component_id: self.document.component_icon(component_id) for component_id in component.implementation.graph.components}
|
||||
port_metadata = {port_id: self.document.port_metadata(port_id) for child in component.implementation.graph.components.values() for port_id in child.interface.ports}
|
||||
self.window.graph_editor.set_component(component, graph, icons, port_metadata)
|
||||
self.window.graph_editor.show()
|
||||
else:
|
||||
self.window.graph_editor.set_component(None)
|
||||
self.window.graph_editor.hide()
|
||||
|
||||
def _on_equation_text_changed(self, component: Component, section: str) -> None:
|
||||
if self.window.equation_editor.component() is component:
|
||||
self.window.equation_editor.refresh_text(section)
|
||||
|
||||
def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None:
|
||||
component = self._components.get(component_id)
|
||||
if component is None:
|
||||
return
|
||||
self.model.set_component_icon(component_id, render_fitted_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components:
|
||||
self._show_component(graph_component)
|
||||
|
||||
def _on_port_metadata_database_changed(self, _database: object) -> None:
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None:
|
||||
self._show_component(graph_component)
|
||||
equation_component = self.window.equation_editor.component()
|
||||
if equation_component is not None:
|
||||
self.window.equation_editor.refresh_port_metadata({port_id: self.document.port_metadata(port_id) for port_id in equation_component.interface.ports})
|
||||
|
||||
def _change_equation_port_metadata(self, component: Component, port_metadata: dict[PortID, PortMetadata]) -> None:
|
||||
self.document.update_component_ports(component, component.interface.ports, port_metadata)
|
||||
|
||||
def _on_graph_component_position_changed(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
||||
self.window.graph_editor.set_component_position(component_id, position)
|
||||
|
||||
def _on_graph_component_label_changed(self, graph_id: ComponentID, component_id: ComponentID, label: object) -> None:
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
||||
self.window.graph_editor.set_component_label(component_id, label if isinstance(label, GraphComponentLabel) else None)
|
||||
|
||||
def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
||||
self.window.graph_editor.set_connection_points(connection_id, points)
|
||||
|
||||
def _collect_components(self, components: dict[ComponentID, Component]) -> None:
|
||||
for component_id, component in components.items():
|
||||
self._components[component_id] = component
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
self._collect_components(component.implementation.graph.components)
|
||||
|
||||
def _show_context_menu(self, position: QPoint) -> None:
|
||||
index = self.window.ui.documentTree.indexAt(position)
|
||||
component = self.model.value(index)
|
||||
if not isinstance(component, Component):
|
||||
return
|
||||
value = self.model.value(index)
|
||||
global_position = self.window.ui.documentTree.viewport().mapToGlobal(position)
|
||||
if isinstance(value, CoreDocument):
|
||||
self._show_root_context_menu(global_position)
|
||||
elif isinstance(value, Component):
|
||||
self._show_component_context_menu(value, global_position)
|
||||
|
||||
def _show_root_context_menu(self, global_position: QPoint) -> None:
|
||||
menu = QMenu(self.window.ui.documentTree)
|
||||
add_graph_component = menu.addAction("Add Graph Component")
|
||||
add_equation_component = menu.addAction("Add Equation Component")
|
||||
selected = menu.exec(global_position)
|
||||
if selected is add_graph_component:
|
||||
self.document.add_empty_root_graph_component()
|
||||
elif selected is add_equation_component:
|
||||
self.document.add_empty_root_equation_component()
|
||||
|
||||
def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None:
|
||||
component = self._components.get(component_id)
|
||||
if component is not None:
|
||||
self._show_component_context_menu(component, global_position, component_id)
|
||||
|
||||
def _open_graph_component(self, component_id: ComponentID) -> None:
|
||||
index = self.model.component_index(component_id)
|
||||
if index.isValid():
|
||||
self.window.ui.documentTree.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
|
||||
self.window.ui.documentTree.scrollTo(index)
|
||||
|
||||
def _show_component_context_menu(self, component: Component, global_position: QPoint, graph_component_id: ComponentID | None = None) -> None:
|
||||
menu = QMenu(self.window.ui.documentTree)
|
||||
edit_interface = menu.addAction("Edit Interface")
|
||||
edit_params = menu.addAction("Edit Parameters")
|
||||
selected = menu.exec(
|
||||
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
||||
)
|
||||
edit_icon = menu.addAction("Edit Icon")
|
||||
show_label = None
|
||||
if graph_component_id is not None:
|
||||
show_label = menu.addAction("Show Label")
|
||||
show_label.setCheckable(True)
|
||||
show_label.setChecked(self.window.graph_editor.component_label_visible(graph_component_id))
|
||||
menu.addSeparator()
|
||||
add_graph_component = None
|
||||
add_equation_component = None
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
add_graph_component = menu.addAction("Add Graph Component")
|
||||
add_equation_component = menu.addAction("Add Equation Component")
|
||||
menu.addSeparator()
|
||||
delete_component = menu.addAction("Delete Component")
|
||||
selected = menu.exec(global_position)
|
||||
if selected is edit_interface:
|
||||
self._edit_interface(component)
|
||||
elif selected is edit_params:
|
||||
self._edit_params(component)
|
||||
elif selected is edit_icon:
|
||||
self._edit_icon(component)
|
||||
elif show_label is not None and selected is show_label:
|
||||
graph_component = self.window.graph_editor.component()
|
||||
if graph_component is not None:
|
||||
self.document.set_graph_component_label_visible(graph_component, graph_component_id, show_label.isChecked())
|
||||
elif add_graph_component is not None and selected is add_graph_component:
|
||||
self._add_graph_component(component)
|
||||
elif add_equation_component is not None and selected is add_equation_component:
|
||||
self._add_equation_component(component)
|
||||
elif selected is delete_component:
|
||||
self._delete_component(component)
|
||||
|
||||
def _edit_interface(self, component: Component) -> None:
|
||||
dialog = self.interface_editor_factory(
|
||||
component.interface.ports,
|
||||
{port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports},
|
||||
self.window,
|
||||
)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.document.update_component_ports(component, dialog.ports())
|
||||
self.document.update_component_ports(component, dialog.ports(), dialog.port_metadata())
|
||||
|
||||
def _edit_params(self, component: Component) -> None:
|
||||
dialog = self.param_editor_factory(
|
||||
@@ -105,3 +272,51 @@ class DocumentTreeController(QObject):
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.document.update_component_params(component, dialog.params())
|
||||
|
||||
def _edit_icon(self, component: Component) -> None:
|
||||
component_id = self.document.component_id(component)
|
||||
editor = IconEditorWindow(self.document.component_icon(component_id), component.interface.ports, self.window)
|
||||
editor.saved.connect(partial(self.document.change_icon, component_id))
|
||||
editor.destroyed.connect(partial(self._icon_editor_closed, editor))
|
||||
self._icon_editors.append(editor)
|
||||
editor.show()
|
||||
|
||||
def _icon_editor_closed(self, editor: IconEditorWindow, *_args: object) -> None:
|
||||
if editor in self._icon_editors:
|
||||
self._icon_editors.remove(editor)
|
||||
|
||||
def _add_graph_component(self, component: Component) -> None:
|
||||
self.document.add_empty_graph_component(component)
|
||||
|
||||
def _add_equation_component(self, component: Component) -> None:
|
||||
self.document.add_empty_equation_component(component)
|
||||
|
||||
def deselect(self) -> None:
|
||||
self.window.ui.documentTree.selectionModel().clear()
|
||||
|
||||
def delete_selected_component(self) -> None:
|
||||
focused = self.window.ui.documentTree.hasFocus()
|
||||
if focused:
|
||||
self.document.delete_components(self._selected_components())
|
||||
|
||||
def _delete_component(self, component: Component) -> None:
|
||||
self.document.delete_component(component)
|
||||
|
||||
def _selected_components(self) -> list[Component]:
|
||||
indexes = self.window.ui.documentTree.selectionModel().selectedRows(0)
|
||||
selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
|
||||
components: list[Component] = []
|
||||
for index in indexes:
|
||||
component = self.model.value(index)
|
||||
if not isinstance(component, Component):
|
||||
continue
|
||||
parent = index.parent()
|
||||
nested = False
|
||||
while parent.isValid():
|
||||
value = self.model.value(parent)
|
||||
if isinstance(value, Component) and id(value) in selected:
|
||||
nested = True
|
||||
break
|
||||
parent = parent.parent()
|
||||
if not nested:
|
||||
components.append(component)
|
||||
return components
|
||||
|
||||
81
src/bedit_gui/controllers/library_controller.py
Normal file
81
src/bedit_gui/controllers/library_controller.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from PySide6.QtCore import QObject, QSize, Qt
|
||||
from PySide6.QtWidgets import QAbstractItemView, QHeaderView
|
||||
|
||||
from bedit_core.models import Component, ComponentID, GraphImplementation, PortID
|
||||
from bedit_gui.models import Icon, IconDatabase, PortMetadata, PortMetadataDatabase
|
||||
from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.services.component_clipboard import export_component_data
|
||||
from bedit_gui.services.libraries import load_library_documents
|
||||
from bedit_gui.utils.icon import render_fitted_icon
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.views.models.library_tree_model import LibraryTreeModel
|
||||
|
||||
ICON_SIZE = QSize(32, 32)
|
||||
|
||||
|
||||
class LibraryController(QObject):
|
||||
def __init__(self, window: MainWindow, settings: ApplicationSettings) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.settings = settings
|
||||
self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon], dict[PortID, PortMetadata]]] = {}
|
||||
self.model = LibraryTreeModel(self._component_payload)
|
||||
|
||||
tree = window.ui.libraryTree
|
||||
tree.setModel(self.model)
|
||||
tree.setHeaderHidden(True)
|
||||
tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
tree.setDragEnabled(True)
|
||||
tree.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
tree.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
tree.setIconSize(QSize(48, 48))
|
||||
tree.header().setStretchLastSection(False)
|
||||
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
tree.setColumnWidth(1, 56)
|
||||
|
||||
window.ui.actionReload_Libraries.triggered.connect(self.reload)
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
libraries = load_library_documents(self.settings.library_paths)
|
||||
self._component_sources = {}
|
||||
for library in libraries:
|
||||
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
|
||||
icons = database.icons if isinstance(database, IconDatabase) else {}
|
||||
metadata_database = library.document.metadata.get("port_metadata_database") if library.document.metadata is not None else None
|
||||
port_metadata = metadata_database.ports if isinstance(metadata_database, PortMetadataDatabase) else {}
|
||||
self._collect_component_sources(library.document.root, icons, port_metadata)
|
||||
self.model.set_documents([library.document for library in libraries])
|
||||
for library in libraries:
|
||||
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
|
||||
icons = database.icons if isinstance(database, IconDatabase) else {}
|
||||
self._set_component_icons(library.document.root, icons)
|
||||
self.window.ui.libraryTree.expandAll()
|
||||
|
||||
def _component_payload(self, components: list[Component]) -> dict:
|
||||
roots = {}
|
||||
icons = {}
|
||||
port_metadata = {}
|
||||
for component in components:
|
||||
source = self._component_sources.get(id(component))
|
||||
if source is None:
|
||||
continue
|
||||
component_id, source_icons, source_port_metadata = source
|
||||
roots[component_id] = component
|
||||
icons.update(source_icons)
|
||||
port_metadata.update(source_port_metadata)
|
||||
return export_component_data(roots, icons, port_metadata)
|
||||
|
||||
def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata]) -> None:
|
||||
for component_id, component in components.items():
|
||||
self._component_sources[id(component)] = (component_id, icons, port_metadata)
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
self._collect_component_sources(component.implementation.graph.components, icons, port_metadata)
|
||||
|
||||
def _set_component_icons(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
|
||||
for component_id, component in components.items():
|
||||
self.model.set_component_icon(component_id, render_fitted_icon(icons.get(component_id, Icon()), component.interface.ports, ICON_SIZE))
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
self._set_component_icons(component.implementation.graph.components, icons)
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtWidgets import QAbstractItemView
|
||||
|
||||
from bedit_gui.services.application_logging import configure_logging
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_gui.views.models import LogListModel
|
||||
|
||||
|
||||
@@ -30,7 +32,7 @@ class LogController(QObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window: MainWindow,
|
||||
window: MainWindow | SimulationWindow,
|
||||
level: int | str = logging.INFO,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
@@ -48,4 +50,6 @@ class LogController(QObject):
|
||||
self.emitter.message.connect(self.model.append)
|
||||
self.model.rowsInserted.connect(window.ui.listView.scrollToBottom)
|
||||
window.ui.listView.setModel(self.model)
|
||||
window.ui.listView.setWordWrap(True)
|
||||
window.ui.listView.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
configure_logging(self.handler, level)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
from bedit_gui.services.application_logging import get_logger, set_log_level
|
||||
@@ -18,15 +18,23 @@ class SettingsDialogLike(Protocol):
|
||||
@property
|
||||
def log_level(self) -> int: ...
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int: ...
|
||||
|
||||
@property
|
||||
def library_paths(self) -> list[str]: ...
|
||||
|
||||
def exec(self) -> int: ...
|
||||
|
||||
|
||||
SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike]
|
||||
SettingsDialogFactory = Callable[[int, int, list[str], MainWindow], SettingsDialogLike]
|
||||
|
||||
|
||||
class SettingsController(QObject):
|
||||
"""Opens the settings dialog and applies accepted preferences."""
|
||||
|
||||
library_paths_changed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window: MainWindow,
|
||||
@@ -41,10 +49,16 @@ class SettingsController(QObject):
|
||||
window.ui.actionSettings.triggered.connect(self.open_settings)
|
||||
|
||||
def open_settings(self) -> None:
|
||||
dialog = self.dialog_factory(self.settings.log_level, self.window)
|
||||
dialog = self.dialog_factory(self.settings.log_level, self.settings.snap_to_grid_size, self.settings.library_paths, self.window)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
old_library_paths = self.settings.library_paths
|
||||
self.settings.log_level = dialog.log_level
|
||||
self.settings.snap_to_grid_size = dialog.snap_to_grid_size
|
||||
self.settings.library_paths = dialog.library_paths
|
||||
self.window.graph_editor.set_snap_to_grid_size(dialog.snap_to_grid_size)
|
||||
set_log_level(dialog.log_level)
|
||||
if self.settings.library_paths != old_library_paths:
|
||||
self.library_paths_changed.emit()
|
||||
logger.info("Application settings updated")
|
||||
|
||||
103
src/bedit_gui/controllers/simulation_controller.py
Normal file
103
src/bedit_gui/controllers/simulation_controller.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, QProcess, Qt
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bedit_core.models import Component
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services import simulation_files
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.services.simulation_loader import component_choices
|
||||
from bedit_gui.services.simulation_handoff import send_simulation_handoff
|
||||
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_simulation import ModelBuildResult, compile_component_sync
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
Compiler = Callable[[Component, str | Path], ModelBuildResult]
|
||||
Launcher = Callable[[Path, CompiledModel], bool]
|
||||
|
||||
|
||||
def _launch_simulator(path: Path, compiled_model: CompiledModel) -> bool:
|
||||
if send_simulation_handoff(path, compiled_model):
|
||||
return True
|
||||
arguments = ["-m", "bedit_gui.simulation_application", "--handoff", "--model-name", compiled_model.model_name, "--executable", compiled_model.executable, "--working-directory", compiled_model.working_directory, str(path)]
|
||||
launched = QProcess.startDetached(sys.executable, arguments)
|
||||
return launched[0] if isinstance(launched, tuple) else bool(launched)
|
||||
|
||||
|
||||
class SimulationController(QObject):
|
||||
"""Compile the active BEdit simulation and launch the simulator process."""
|
||||
|
||||
def __init__(self, document: Document, window: MainWindow, compiler: Compiler = compile_component_sync, launcher: Launcher = _launch_simulator) -> None:
|
||||
super().__init__(window)
|
||||
self.document = document
|
||||
self.window = window
|
||||
self.compiler = compiler
|
||||
self.launcher = launcher
|
||||
self.compiled_root: SimulationRoot | None = None
|
||||
self.compiled_model: CompiledModel | None = None
|
||||
window.ui.actionCompile_Model.triggered.connect(self.compile_model)
|
||||
window.ui.actionOpen_Simulation_Window.triggered.connect(self.open_simulation)
|
||||
|
||||
def compile_model(self) -> SimulationRoot | None:
|
||||
database = self.document.simulation_database()
|
||||
settings = database.simulations.get(database.active_simulation) if database.active_simulation is not None else None
|
||||
if settings is None:
|
||||
QMessageBox.warning(self.window, "No active simulation", "Select and accept a simulation settings block first.")
|
||||
return None
|
||||
match = next((choice for choice in component_choices(self.document.model) if choice[0] == settings.component), None)
|
||||
if match is None:
|
||||
QMessageBox.critical(self.window, "Could not compile", "The active simulation references a missing component.")
|
||||
return None
|
||||
component_id, component, component_path = match
|
||||
build_directory = Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
self.window.statusBar().showMessage(f"Compiling {component_path}…")
|
||||
logger.info("Compiling model: %s", component_path)
|
||||
try:
|
||||
self.document.infer_causality(component)
|
||||
build = self.compiler(component, build_directory)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
logger.error("Could not compile model %s:\n%s", component_path, exc)
|
||||
QMessageBox.critical(self.window, "Could not compile", str(exc))
|
||||
self.window.statusBar().showMessage("Compilation failed")
|
||||
return None
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self.compiled_model = CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors)
|
||||
self.compiled_root = SimulationRoot(
|
||||
format_version=1,
|
||||
source_document=str(self.document.path.resolve()) if self.document.path is not None else None,
|
||||
source_document_id=str(self.document.model.id),
|
||||
component=component_id,
|
||||
component_path=component_path,
|
||||
settings_name=settings.name,
|
||||
settings=settings,
|
||||
)
|
||||
self.window.statusBar().showMessage(f"Compiled {component_path}")
|
||||
logger.info("Compiled model %s: %s", build.model_name, self.compiled_model.executable)
|
||||
if build.output.strip():
|
||||
logger.info("Compiler output:\n%s", build.output.strip())
|
||||
if build.errors.strip():
|
||||
logger.warning("Compiler errors:\n%s", build.errors.strip())
|
||||
return self.compiled_root
|
||||
|
||||
def open_simulation(self) -> None:
|
||||
root = self.compile_model()
|
||||
if root is None or self.compiled_model is None:
|
||||
return
|
||||
transfer_directory = Path(tempfile.mkdtemp(prefix="bedit-simulator-launch-"))
|
||||
transfer_path = transfer_directory / "simulation.bes"
|
||||
try:
|
||||
simulation_files.save(root, transfer_path)
|
||||
if not self.launcher(transfer_path, self.compiled_model):
|
||||
raise RuntimeError("the simulator process could not be started")
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
QMessageBox.critical(self.window, "Could not open simulator", str(exc))
|
||||
181
src/bedit_gui/controllers/simulation_file_controller.py
Normal file
181
src/bedit_gui/controllers/simulation_file_controller.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
|
||||
|
||||
from bedit_gui.services import document_files, simulation_files
|
||||
from bedit_gui.services.simulation_loader import compile_simulation_root, component_choices, load_and_compile_bedit
|
||||
from bedit_gui.models import SimulationDatabase, SimulationID
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
|
||||
from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SimulationFileController(QObject):
|
||||
runtime_changed = Signal()
|
||||
|
||||
def __init__(self, window: SimulationWindow) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.root: SimulationRoot | None = None
|
||||
self.compiled_model: CompiledModel | None = None
|
||||
self.path: Path | None = None
|
||||
window.ui.actionNew_Simulation_Run.triggered.connect(self.new)
|
||||
window.ui.actionOpen_Simulation_Run.triggered.connect(self.open_dialog)
|
||||
window.ui.actionSave_Simulation_Run.triggered.connect(self.save)
|
||||
window.ui.actionSimulation_Options.triggered.connect(self.open_simulation_settings)
|
||||
self._update_window()
|
||||
|
||||
def new(self) -> None:
|
||||
self.root = None
|
||||
self.compiled_model = None
|
||||
self.path = None
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Created new simulation")
|
||||
|
||||
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None, preserve_plots: bool = False) -> None:
|
||||
file_path = Path(path)
|
||||
plot_tabs = deepcopy(self.root.plot_tabs) if preserve_plots and self.root is not None else None
|
||||
try:
|
||||
if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path):
|
||||
self.root = simulation_files.load(file_path)
|
||||
self.compiled_model = compiled_model or self._recompile_with_wait_cursor(self.root)
|
||||
self.path = file_path if backed_by_file else None
|
||||
else:
|
||||
self.root, self.compiled_model = self._compile_with_wait_cursor(file_path, component=component, simulation=simulation)
|
||||
self.path = None
|
||||
except (OSError, RuntimeError, TypeError, ValueError):
|
||||
logger.exception("Could not open simulation %s", file_path)
|
||||
raise
|
||||
if preserve_plots and self.root is not None:
|
||||
if plot_tabs is not None:
|
||||
self.root.plot_tabs = plot_tabs
|
||||
self.root.current_end_time = self.root.settings.start_time
|
||||
self.root.results.clear()
|
||||
self._log_compilation()
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Opened simulation: %s", file_path)
|
||||
|
||||
def open_handoff(self, path: str | Path, compiled_model: CompiledModel) -> None:
|
||||
self.open(path, backed_by_file=False, compiled_model=compiled_model, preserve_plots=True)
|
||||
logger.info("Accepted simulation handoff and reset to time %s", self.root.current_end_time if self.root is not None else "unknown")
|
||||
|
||||
def open_dialog(self) -> None:
|
||||
filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)")
|
||||
if not filename:
|
||||
return
|
||||
try:
|
||||
path = Path(filename)
|
||||
if path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(path):
|
||||
self.open(path)
|
||||
return
|
||||
document = document_files.load(path)
|
||||
database = document.metadata.get("simulation_database") if document.metadata is not None else None
|
||||
entries = [(f"Simulation: {simulation.name}", None, simulation.name) for simulation in getattr(database, "simulations", {}).values()]
|
||||
entries.extend((f"Component: {component_path}", component_path, None) for _component_id, _component, component_path in component_choices(document))
|
||||
if not entries:
|
||||
raise ValueError("the BEdit document contains no components or simulation settings")
|
||||
label, accepted = QInputDialog.getItem(self.window, "Simulation Target", "Compile:", [entry[0] for entry in entries], 0, False)
|
||||
if accepted:
|
||||
_display, component, simulation = next(entry for entry in entries if entry[0] == label)
|
||||
self.open(path, component=component, simulation=simulation)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
QMessageBox.critical(self.window, "Could not open simulation", str(exc))
|
||||
|
||||
def save(self) -> None:
|
||||
if self.root is None:
|
||||
return
|
||||
path = self.path
|
||||
if path is None:
|
||||
filename, _ = QFileDialog.getSaveFileName(self.window, "Save Simulation", "simulation.bes", "BEdit simulation (*.bes);;Simulation JSON (*.json)")
|
||||
if not filename:
|
||||
return
|
||||
path = Path(filename)
|
||||
if path.suffix.lower() not in (".bes", ".json"):
|
||||
path = path.with_suffix(".bes")
|
||||
try:
|
||||
simulation_files.save(self.root, path)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not save simulation %s", path)
|
||||
QMessageBox.critical(self.window, "Could not save simulation", str(exc))
|
||||
return
|
||||
self.path = path
|
||||
self._update_window()
|
||||
logger.info("Saved simulation: %s", path)
|
||||
|
||||
def open_simulation_settings(self) -> None:
|
||||
if self.root is None:
|
||||
return
|
||||
simulation_id = SimulationID()
|
||||
database = SimulationDatabase(simulations={simulation_id: self.root.settings}, active_simulation=simulation_id)
|
||||
components = self._source_components()
|
||||
dialog = SimulationSettingsDialog(database, [(component_id, path) for component_id, _component, path in components], self.window, show_simulation_list=False)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
updated = dialog.database().simulations[simulation_id]
|
||||
component_changed = updated.component != self.root.component
|
||||
self.root.settings = updated
|
||||
self.root.component = updated.component
|
||||
self.root.component_path = next((path for component_id, _component, path in components if component_id == updated.component), self.root.component_path)
|
||||
self.root.settings_name = updated.name
|
||||
self.root.current_end_time = updated.start_time
|
||||
self.root.results.clear()
|
||||
if component_changed:
|
||||
self.compiled_model = None
|
||||
self._update_window()
|
||||
self.runtime_changed.emit()
|
||||
logger.info("Updated simulation settings: %s", updated.name)
|
||||
|
||||
def _source_components(self) -> list[tuple]:
|
||||
if self.root is not None and self.root.source_document is not None:
|
||||
try:
|
||||
return component_choices(document_files.load(self.root.source_document))
|
||||
except (OSError, TypeError, ValueError):
|
||||
logger.warning("Could not load source components from %s", self.root.source_document, exc_info=True)
|
||||
if self.root is None:
|
||||
return []
|
||||
return [(self.root.component, None, self.root.component_path)]
|
||||
|
||||
def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]:
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel:
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
return compile_simulation_root(root)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
def _update_window(self) -> None:
|
||||
if self.root is None:
|
||||
self.window.setWindowTitle("BEdit Simulator")
|
||||
self.window.statusBar().showMessage("No simulation loaded")
|
||||
self.window.ui.actionSave_Simulation_Run.setEnabled(False)
|
||||
self.window.ui.actionSimulation_Options.setEnabled(False)
|
||||
return
|
||||
self.window.setWindowTitle(f"{self.root.settings.name} — BEdit Simulator")
|
||||
compiled = self.compiled_model.executable if self.compiled_model is not None else "not compiled"
|
||||
self.window.statusBar().showMessage(f"{self.root.component_path} · {compiled}")
|
||||
self.window.ui.actionSave_Simulation_Run.setEnabled(True)
|
||||
self.window.ui.actionSimulation_Options.setEnabled(True)
|
||||
|
||||
def _log_compilation(self) -> None:
|
||||
if self.compiled_model is None:
|
||||
return
|
||||
logger.info("Compiled model %s: %s", self.compiled_model.model_name, self.compiled_model.executable)
|
||||
if self.compiled_model.output.strip():
|
||||
logger.info("Compiler output:\n%s", self.compiled_model.output.strip())
|
||||
if self.compiled_model.errors.strip():
|
||||
logger.warning("Compiler errors:\n%s", self.compiled_model.errors.strip())
|
||||
57
src/bedit_gui/controllers/simulation_handoff_controller.py
Normal file
57
src/bedit_gui/controllers/simulation_handoff_controller.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.services.simulation_handoff import SimulationHandoffServer
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SimulationHandoffController(QObject):
|
||||
def __init__(self, window: SimulationWindow, files: SimulationFileController, runs: SimulationRunController) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.files = files
|
||||
self.runs = runs
|
||||
self._pending: tuple[Path, CompiledModel] | None = None
|
||||
self.server = SimulationHandoffServer(self)
|
||||
self.server.handoff_received.connect(self.receive)
|
||||
runs.run_completed.connect(self._run_finished)
|
||||
runs.run_cancelled.connect(self._run_finished)
|
||||
runs.run_failed.connect(self._run_finished)
|
||||
|
||||
def receive(self, path: Path, compiled_model: CompiledModel) -> None:
|
||||
self._pending = (path, compiled_model)
|
||||
if self.runs.is_running:
|
||||
logger.info("Received a new model; stopping the active simulation before handoff")
|
||||
self.runs.stop()
|
||||
return
|
||||
self._apply_pending()
|
||||
|
||||
def _run_finished(self, *_args: object) -> None:
|
||||
if self._pending is not None:
|
||||
self._apply_pending()
|
||||
|
||||
def _apply_pending(self) -> None:
|
||||
pending = self._pending
|
||||
self._pending = None
|
||||
if pending is None:
|
||||
return
|
||||
path, compiled_model = pending
|
||||
try:
|
||||
self.files.open_handoff(path, compiled_model)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not accept simulation handoff from %s", path)
|
||||
QMessageBox.critical(self.window, "Could not open simulation", str(exc))
|
||||
return
|
||||
self.window.show()
|
||||
self.window.raise_()
|
||||
self.window.activateWindow()
|
||||
272
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
272
src/bedit_gui/controllers/simulation_plot_controller.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QUndoStack
|
||||
from PySide6.QtWidgets import QDialog, QInputDialog, QMenu, QTreeWidgetItem, QWidget
|
||||
|
||||
from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSettingsCommand, ChangeSimulationPlotSignalsCommand, ChangeSimulationPlotXAxisCommand, RemoveSimulationPlotTabCommand, RenameSimulationPlotTabCommand
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.simulation_models import SimulationPlotTab
|
||||
from bedit_gui.views.simulation_plot_widget import SimulationPlotWidget
|
||||
from bedit_gui.views.dialogs.plot_settings_dialog import PlotSettingsDialog
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _signal_tree_parts(signal: str) -> list[str]:
|
||||
if signal.startswith("der(") and signal.endswith(")"):
|
||||
parts = signal[4:-1].split(".")
|
||||
if len(parts) > 1:
|
||||
return [*parts[:-1], f"der({parts[-1]})"]
|
||||
return signal.split(".")
|
||||
|
||||
|
||||
class SimulationPlotController(QObject):
|
||||
def __init__(self, window: SimulationWindow, files: SimulationFileController) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.files = files
|
||||
self.undo_stack = QUndoStack(self)
|
||||
self._updating = False
|
||||
self._active_tab = -1
|
||||
|
||||
tabs = window.ui.resultsTabWidget
|
||||
tree = window.ui.simulationTree
|
||||
tabs.currentChanged.connect(self._tab_changed)
|
||||
tabs.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
tabs.tabBar().customContextMenuRequested.connect(self._show_tab_menu)
|
||||
tree.itemChanged.connect(self._signal_changed)
|
||||
tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
tree.customContextMenuRequested.connect(self._show_signal_menu)
|
||||
files.runtime_changed.connect(self.load_root)
|
||||
|
||||
window.ui.actionUndo.triggered.connect(self.undo)
|
||||
window.ui.actionRedo.triggered.connect(self.redo)
|
||||
self.undo_stack.canUndoChanged.connect(window.ui.actionUndo.setEnabled)
|
||||
self.undo_stack.canRedoChanged.connect(window.ui.actionRedo.setEnabled)
|
||||
self.undo_stack.undoTextChanged.connect(self._set_undo_text)
|
||||
self.undo_stack.redoTextChanged.connect(self._set_redo_text)
|
||||
window.ui.actionUndo.setEnabled(False)
|
||||
window.ui.actionRedo.setEnabled(False)
|
||||
self.load_root()
|
||||
|
||||
def load_root(self) -> None:
|
||||
self.undo_stack.clear()
|
||||
self._active_tab = 0 if self.files.root is not None and self.files.root.plot_tabs else -1
|
||||
self._rebuild_tabs(self._active_tab)
|
||||
self._rebuild_tree()
|
||||
|
||||
def refresh_results(self) -> None:
|
||||
self._rebuild_tree()
|
||||
self._refresh_plots()
|
||||
|
||||
def add_tab(self) -> None:
|
||||
root = self.files.root
|
||||
if root is None:
|
||||
return
|
||||
used_names = {tab.name for tab in root.plot_tabs}
|
||||
number = 1
|
||||
while f"Plot {number}" in used_names:
|
||||
number += 1
|
||||
index = len(root.plot_tabs)
|
||||
self.undo_stack.push(AddSimulationPlotTabCommand(root, SimulationPlotTab(f"Plot {number}"), index, self._tabs_changed))
|
||||
|
||||
def rename_tab(self, index: int, name: str) -> None:
|
||||
root = self.files.root
|
||||
name = name.strip()
|
||||
if root is None or not 0 <= index < len(root.plot_tabs) or not name or root.plot_tabs[index].name == name:
|
||||
return
|
||||
self.undo_stack.push(RenameSimulationPlotTabCommand(root, index, name, self._tabs_changed))
|
||||
|
||||
def remove_tab(self, index: int) -> None:
|
||||
root = self.files.root
|
||||
if root is None or not 0 <= index < len(root.plot_tabs):
|
||||
return
|
||||
self.undo_stack.push(RemoveSimulationPlotTabCommand(root, index, self._tabs_changed))
|
||||
|
||||
def undo(self) -> None:
|
||||
command = self.undo_stack.undoText()
|
||||
self.undo_stack.undo()
|
||||
logger.info("Undo: %s", command)
|
||||
|
||||
def redo(self) -> None:
|
||||
command = self.undo_stack.redoText()
|
||||
self.undo_stack.redo()
|
||||
logger.info("Redo: %s", command)
|
||||
|
||||
def _tabs_changed(self, selected_index: int) -> None:
|
||||
root = self.files.root
|
||||
if root is None or not root.plot_tabs:
|
||||
self._active_tab = -1
|
||||
else:
|
||||
self._active_tab = max(0, min(selected_index, len(root.plot_tabs) - 1))
|
||||
self._rebuild_tabs(self._active_tab)
|
||||
self._rebuild_tree()
|
||||
|
||||
def _selection_changed(self, index: int) -> None:
|
||||
self._active_tab = index
|
||||
self._sync_tree_checks()
|
||||
self._refresh_plot(index)
|
||||
|
||||
def _rebuild_tabs(self, selected_index: int) -> None:
|
||||
tabs = self.window.ui.resultsTabWidget
|
||||
root = self.files.root
|
||||
self._updating = True
|
||||
try:
|
||||
while tabs.count():
|
||||
tabs.removeTab(0)
|
||||
if root is not None:
|
||||
for index, tab in enumerate(root.plot_tabs):
|
||||
widget = SimulationPlotWidget(tabs)
|
||||
widget.settings_requested.connect(lambda checked=False, tab_index=index: self._open_plot_settings(tab_index))
|
||||
tabs.addTab(widget, tab.name)
|
||||
tabs.addTab(QWidget(tabs), "+")
|
||||
tabs.setCurrentIndex(selected_index if selected_index >= 0 else tabs.count() - 1)
|
||||
finally:
|
||||
self._updating = False
|
||||
self._refresh_plots()
|
||||
|
||||
def _rebuild_tree(self) -> None:
|
||||
tree = self.window.ui.simulationTree
|
||||
signals = self._available_signals()
|
||||
self._updating = True
|
||||
try:
|
||||
tree.clear()
|
||||
nodes: dict[tuple[str, ...], QTreeWidgetItem] = {}
|
||||
for signal in signals:
|
||||
parent = tree.invisibleRootItem()
|
||||
parts = _signal_tree_parts(signal)
|
||||
for depth, part in enumerate(parts, start=1):
|
||||
path = tuple(parts[:depth])
|
||||
item = nodes.get(path)
|
||||
if item is None:
|
||||
item = QTreeWidgetItem(parent, [part])
|
||||
nodes[path] = item
|
||||
parent = item
|
||||
item.setData(0, Qt.ItemDataRole.UserRole, signal)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
self._sync_tree_checks()
|
||||
finally:
|
||||
self._updating = False
|
||||
|
||||
def _sync_tree_checks(self) -> None:
|
||||
tree = self.window.ui.simulationTree
|
||||
root = self.files.root
|
||||
selected = set(root.plot_tabs[self._active_tab].signals) if root is not None and 0 <= self._active_tab < len(root.plot_tabs) else set()
|
||||
previous = self._updating
|
||||
self._updating = True
|
||||
try:
|
||||
iterator = tree.invisibleRootItem()
|
||||
pending = [iterator.child(index) for index in range(iterator.childCount())]
|
||||
while pending:
|
||||
item = pending.pop()
|
||||
signal = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if signal is not None:
|
||||
item.setCheckState(0, Qt.CheckState.Checked if signal in selected else Qt.CheckState.Unchecked)
|
||||
pending.extend(item.child(index) for index in range(item.childCount()))
|
||||
finally:
|
||||
self._updating = previous
|
||||
|
||||
def _signal_changed(self, item: QTreeWidgetItem, _column: int) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
root = self.files.root
|
||||
signal = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs):
|
||||
return
|
||||
selected = set(root.plot_tabs[self._active_tab].signals)
|
||||
if item.checkState(0) == Qt.CheckState.Checked:
|
||||
selected.add(signal)
|
||||
else:
|
||||
selected.discard(signal)
|
||||
ordered = [available for available in self._available_signals() if available in selected]
|
||||
if ordered != root.plot_tabs[self._active_tab].signals:
|
||||
self.undo_stack.push(ChangeSimulationPlotSignalsCommand(root, self._active_tab, ordered, self._selection_changed))
|
||||
|
||||
def _tab_changed(self, index: int) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
root = self.files.root
|
||||
real_tab_count = len(root.plot_tabs) if root is not None else 0
|
||||
if index == real_tab_count:
|
||||
self.add_tab()
|
||||
return
|
||||
if 0 <= index < real_tab_count:
|
||||
self._active_tab = index
|
||||
self._sync_tree_checks()
|
||||
|
||||
def _show_tab_menu(self, position: QPoint) -> None:
|
||||
root = self.files.root
|
||||
tab_bar = self.window.ui.resultsTabWidget.tabBar()
|
||||
index = tab_bar.tabAt(position)
|
||||
if root is None or not 0 <= index < len(root.plot_tabs):
|
||||
return
|
||||
menu = QMenu(tab_bar)
|
||||
rename_action = menu.addAction("Rename")
|
||||
remove_action = menu.addAction("Remove")
|
||||
selected = menu.exec(tab_bar.mapToGlobal(position))
|
||||
if selected is rename_action:
|
||||
name, accepted = QInputDialog.getText(self.window, "Rename Plot", "Name:", text=root.plot_tabs[index].name)
|
||||
if accepted:
|
||||
self.rename_tab(index, name)
|
||||
elif selected is remove_action:
|
||||
self.remove_tab(index)
|
||||
|
||||
def _show_signal_menu(self, position: QPoint) -> None:
|
||||
root = self.files.root
|
||||
tree = self.window.ui.simulationTree
|
||||
item = tree.itemAt(position)
|
||||
signal = item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs):
|
||||
return
|
||||
tab = root.plot_tabs[self._active_tab]
|
||||
menu = QMenu(tree)
|
||||
use_signal = menu.addAction("Use as x axis")
|
||||
use_signal.setEnabled(tab.x_axis != signal)
|
||||
use_time = menu.addAction("Use time as x axis")
|
||||
use_time.setEnabled(tab.x_axis is not None)
|
||||
selected = menu.exec(tree.viewport().mapToGlobal(position))
|
||||
if selected is use_signal:
|
||||
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, signal, self._selection_changed))
|
||||
elif selected is use_time:
|
||||
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, None, self._selection_changed))
|
||||
|
||||
def _open_plot_settings(self, index: int) -> None:
|
||||
root = self.files.root
|
||||
if root is None or not 0 <= index < len(root.plot_tabs):
|
||||
return
|
||||
dialog = PlotSettingsDialog(root.plot_tabs[index].settings, root.plot_tabs[index].signals, self.window)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
settings = dialog.settings()
|
||||
if settings != root.plot_tabs[index].settings:
|
||||
self.undo_stack.push(ChangeSimulationPlotSettingsCommand(root, index, settings, self._selection_changed))
|
||||
|
||||
def _available_signals(self) -> list[str]:
|
||||
root = self.files.root
|
||||
if root is None:
|
||||
return []
|
||||
return sorted({signal for result in root.results for signal in result.data})
|
||||
|
||||
def _refresh_plots(self) -> None:
|
||||
root = self.files.root
|
||||
if root is None:
|
||||
return
|
||||
for index in range(len(root.plot_tabs)):
|
||||
self._refresh_plot(index)
|
||||
|
||||
def _refresh_plot(self, index: int) -> None:
|
||||
root = self.files.root
|
||||
if root is None or not 0 <= index < len(root.plot_tabs):
|
||||
return
|
||||
widget = self.window.ui.resultsTabWidget.widget(index)
|
||||
if isinstance(widget, SimulationPlotWidget):
|
||||
tab = root.plot_tabs[index]
|
||||
widget.set_plot(root.results, tab.signals, tab.x_axis, tab.settings)
|
||||
|
||||
def _set_undo_text(self, command: str) -> None:
|
||||
self.window.ui.actionUndo.setText(f"Undo {command}" if command else "Undo")
|
||||
|
||||
def _set_redo_text(self, command: str) -> None:
|
||||
self.window.ui.actionRedo.setText(f"Redo {command}" if command else "Redo")
|
||||
179
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
179
src/bedit_gui/controllers/simulation_run_controller.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from threading import Thread
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_simulation import SimulationCancelledError, SimulationResult, SimulationRunSettings, SimulationSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
SessionFactory = Callable[[CompiledModel, SimulationRunSettings, float | None, list[SimulationResult]], SimulationSession]
|
||||
|
||||
|
||||
def _create_session(compiled: CompiledModel, settings: SimulationRunSettings, current_end_time: float | None, results: list[SimulationResult]) -> SimulationSession:
|
||||
return SimulationSession(compiled.model_name, compiled.executable, settings, current_end_time=current_end_time, results=results)
|
||||
|
||||
|
||||
class SimulationRunController(QObject):
|
||||
run_completed = Signal(object)
|
||||
run_cancelled = Signal()
|
||||
run_failed = Signal(object)
|
||||
run_progress = Signal(int)
|
||||
simulation_state_changed = Signal()
|
||||
|
||||
def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.files = files
|
||||
self.session_factory = session_factory
|
||||
self.session: SimulationSession | None = None
|
||||
self._running = False
|
||||
self._reset_pending = False
|
||||
self.window.ui.progressBar.setValue(0)
|
||||
|
||||
window.ui.actionRun_Simulation.triggered.connect(self.start)
|
||||
window.ui.actionStop_Simulation.triggered.connect(self.stop)
|
||||
window.ui.actionRestart_Simulation.triggered.connect(self.restart)
|
||||
files.runtime_changed.connect(self._load_session)
|
||||
self.run_completed.connect(self._completed)
|
||||
self.run_cancelled.connect(self._cancelled)
|
||||
self.run_failed.connect(self._failed)
|
||||
self.run_progress.connect(self.window.ui.progressBar.setValue)
|
||||
self._load_session()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running or self.session is None:
|
||||
return
|
||||
start_time = self.session.settings.start_time
|
||||
stop_time = self.session.current_end_time + self.session.settings.duration
|
||||
self._running = True
|
||||
self.window.ui.progressBar.setValue(0)
|
||||
self._update_actions()
|
||||
logger.info("Starting simulation from %s to %s", start_time, stop_time)
|
||||
Thread(target=self._run_worker, args=(self.session,), name="besim-run", daemon=True).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running or self.session is None:
|
||||
return
|
||||
self.session.cancel()
|
||||
logger.info("Stopping simulation")
|
||||
|
||||
def restart(self) -> None:
|
||||
if self.session is None:
|
||||
return
|
||||
if self._running:
|
||||
self._reset_pending = True
|
||||
logger.info("Reset requested; stopping the active simulation")
|
||||
self.stop()
|
||||
return
|
||||
self._reset()
|
||||
|
||||
def _reset(self) -> None:
|
||||
if self.session is None:
|
||||
return
|
||||
self.session.reset()
|
||||
self.window.ui.progressBar.setValue(0)
|
||||
self._store_session_state()
|
||||
logger.info("Reset simulation to start time %s", self.session.current_end_time)
|
||||
self._update_actions()
|
||||
|
||||
def _run_worker(self, session: SimulationSession) -> None:
|
||||
try:
|
||||
result = asyncio.run(self._run_with_progress(session))
|
||||
except SimulationCancelledError:
|
||||
self.run_cancelled.emit()
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
self.run_failed.emit(exc)
|
||||
else:
|
||||
self.run_completed.emit(result)
|
||||
|
||||
async def _run_with_progress(self, session: SimulationSession) -> SimulationResult:
|
||||
task = asyncio.create_task(session.run_next())
|
||||
last_progress = -1
|
||||
while not task.done():
|
||||
progress = min(session.get_progress(), 99)
|
||||
if progress != last_progress:
|
||||
self.run_progress.emit(progress)
|
||||
last_progress = progress
|
||||
await asyncio.sleep(0.1)
|
||||
return await task
|
||||
|
||||
def _completed(self, result: SimulationResult) -> None:
|
||||
self._running = False
|
||||
self._store_session_state()
|
||||
self.window.ui.progressBar.setValue(100)
|
||||
logger.info("Simulation completed at time %s", self.session.current_end_time if self.session is not None else "unknown")
|
||||
if result.process_output.strip():
|
||||
logger.info("Simulation output:\n%s", result.process_output.strip())
|
||||
if result.process_errors.strip():
|
||||
logger.warning("Simulation errors:\n%s", result.process_errors.strip())
|
||||
self._finish_run()
|
||||
|
||||
def _cancelled(self) -> None:
|
||||
self._running = False
|
||||
logger.info("Simulation stopped")
|
||||
self._finish_run()
|
||||
|
||||
def _failed(self, error: Exception) -> None:
|
||||
self._running = False
|
||||
logger.error("Simulation failed: %s", error, exc_info=(type(error), error, error.__traceback__))
|
||||
self._finish_run()
|
||||
|
||||
def _finish_run(self) -> None:
|
||||
self._update_actions()
|
||||
if self._reset_pending:
|
||||
self._reset_pending = False
|
||||
self._reset()
|
||||
|
||||
def _load_session(self) -> None:
|
||||
root = self.files.root
|
||||
compiled = self.files.compiled_model
|
||||
self._reset_pending = False
|
||||
self.window.ui.progressBar.setValue(0)
|
||||
if root is None or compiled is None:
|
||||
self.session = None
|
||||
else:
|
||||
settings = root.settings
|
||||
try:
|
||||
run_settings = SimulationRunSettings(
|
||||
start_time=settings.start_time,
|
||||
duration=settings.duration,
|
||||
use_timed_steps=settings.use_timed_steps,
|
||||
number_of_steps=settings.number_of_steps,
|
||||
step_size=settings.step_size,
|
||||
tolerance=settings.dassl_tolerance,
|
||||
method=settings.method.value,
|
||||
)
|
||||
self.session = self.session_factory(compiled, run_settings, root.current_end_time, root.results)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
self.session = None
|
||||
logger.exception("Could not initialize the simulation runtime")
|
||||
self._update_actions()
|
||||
|
||||
def _update_actions(self) -> None:
|
||||
available = self.session is not None
|
||||
self.window.ui.actionRun_Simulation.setEnabled(available and not self._running)
|
||||
self.window.ui.actionStop_Simulation.setEnabled(available and self._running)
|
||||
self.window.ui.actionRestart_Simulation.setEnabled(available)
|
||||
for action in (self.window.ui.actionNew_Simulation_Run, self.window.ui.actionOpen_Simulation_Run, self.window.ui.actionSave_Simulation_Run, self.window.ui.actionSimulation_Options):
|
||||
action.setEnabled(not self._running and (available or action is not self.window.ui.actionSimulation_Options))
|
||||
if self.session is not None:
|
||||
state = "running" if self._running else "ready"
|
||||
self.window.statusBar().showMessage(f"Simulation {state} · current time {self.session.current_end_time}")
|
||||
|
||||
def _store_session_state(self) -> None:
|
||||
if self.session is None or self.files.root is None:
|
||||
return
|
||||
self.files.root.current_end_time = self.session.current_end_time
|
||||
self.files.root.results = list(self.session.results)
|
||||
self.simulation_state_changed.emit()
|
||||
48
src/bedit_gui/controllers/simulation_settings_controller.py
Normal file
48
src/bedit_gui/controllers/simulation_settings_controller.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
from bedit_core.models import Component, ComponentID, GraphImplementation
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.models import SimulationDatabase
|
||||
from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class SimulationSettingsDialogLike(Protocol):
|
||||
def exec(self) -> int: ...
|
||||
def database(self) -> SimulationDatabase: ...
|
||||
|
||||
|
||||
SimulationSettingsDialogFactory = Callable[[SimulationDatabase, list[tuple[ComponentID, str]], MainWindow], SimulationSettingsDialogLike]
|
||||
|
||||
|
||||
class SimulationSettingsController(QObject):
|
||||
def __init__(self, document: Document, window: MainWindow, dialog_factory: SimulationSettingsDialogFactory = SimulationSettingsDialog) -> None:
|
||||
super().__init__(window)
|
||||
self.document = document
|
||||
self.window = window
|
||||
self.dialog_factory = dialog_factory
|
||||
window.ui.actionSimulation_Settings.triggered.connect(self.open_settings)
|
||||
|
||||
def open_settings(self) -> None:
|
||||
dialog = self.dialog_factory(self.document.simulation_database(), self._components(), self.window)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.document.change_simulation_database(dialog.database())
|
||||
|
||||
def _components(self) -> list[tuple[ComponentID, str]]:
|
||||
components: list[tuple[ComponentID, str]] = []
|
||||
|
||||
def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None:
|
||||
for component_id, component in items.items():
|
||||
component_path = (*path, component.name)
|
||||
components.append((component_id, ".".join(component_path)))
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
collect(component.implementation.graph.components, component_path)
|
||||
|
||||
collect(self.document.model.root)
|
||||
return components
|
||||
@@ -31,11 +31,13 @@ class UndoController(QObject):
|
||||
redo_action.setEnabled(undo_stack.canRedo())
|
||||
|
||||
def undo(self) -> None:
|
||||
self.window.equation_editor.finish_text_edit()
|
||||
command = self.document.undo_stack.undoText()
|
||||
self.document.undo_stack.undo()
|
||||
logger.info("Undo: %s", command)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.window.equation_editor.finish_text_edit()
|
||||
command = self.document.undo_stack.redoText()
|
||||
self.document.undo_stack.redo()
|
||||
logger.info("Redo: %s", command)
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtGui import QUndoStack
|
||||
|
||||
from bedit_core.models import ID, Component, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import ID, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_core.bondgraph import causality_inference
|
||||
from bedit_gui.commands.causality_command import ChangeCausalityCommand, causality_state
|
||||
from bedit_gui.commands.change_icon_command import ChangeIconCommand
|
||||
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
|
||||
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
|
||||
from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand
|
||||
from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand
|
||||
from bedit_gui.commands.graph_label_command import ChangeGraphComponentLabelCommand
|
||||
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
||||
from bedit_gui.commands.port_metadata_command import ChangePortMetadataDatabaseCommand
|
||||
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
|
||||
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
||||
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
|
||||
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
|
||||
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents
|
||||
from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, PortMetadata, PortMetadataDatabase, Simulation, SimulationDatabase
|
||||
from bedit_gui.services import document_files
|
||||
|
||||
|
||||
@@ -20,6 +33,13 @@ class Document(QObject):
|
||||
model_changed = Signal(object)
|
||||
path_changed = Signal(object)
|
||||
modified_changed = Signal(bool)
|
||||
icon_changed = Signal(object, object)
|
||||
equation_text_changed = Signal(object, str)
|
||||
simulation_database_changed = Signal(object)
|
||||
port_metadata_database_changed = Signal(object)
|
||||
graph_component_position_changed = Signal(object, object, object)
|
||||
graph_component_label_changed = Signal(object, object, object)
|
||||
graph_connection_points_changed = Signal(object, object, object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -89,7 +109,255 @@ class Document(QObject):
|
||||
def rename_component(self, component: Component, name: str) -> None:
|
||||
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
||||
|
||||
def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None:
|
||||
def infer_causality(self, component: Component) -> None:
|
||||
inferred_component = deepcopy(component)
|
||||
causality_inference(inferred_component)
|
||||
if causality_state(component) != causality_state(inferred_component):
|
||||
self.undo_stack.push(ChangeCausalityCommand(self, component, inferred_component))
|
||||
|
||||
def component_id(self, component: Component) -> ComponentID:
|
||||
def find(components: dict[ComponentID, Component]) -> ComponentID | None:
|
||||
for component_id, candidate in components.items():
|
||||
if candidate is component:
|
||||
return component_id
|
||||
if isinstance(candidate.implementation, GraphImplementation):
|
||||
found = find(candidate.implementation.graph.components)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
component_id = find(self.model.root)
|
||||
if component_id is None:
|
||||
raise ValueError("component is not part of this document")
|
||||
return component_id
|
||||
|
||||
def stored_component_icon(self, component_id: ComponentID) -> Icon | None:
|
||||
database = self._icon_database(False)
|
||||
return deepcopy(database.icons.get(component_id)) if database is not None else None
|
||||
|
||||
def component_icon(self, component_id: ComponentID) -> Icon:
|
||||
return self.stored_component_icon(component_id) or Icon()
|
||||
|
||||
def graph_database(self) -> GraphDatabase:
|
||||
database = self._graph_database(False)
|
||||
return deepcopy(database) if database is not None else GraphDatabase()
|
||||
|
||||
def stored_port_metadata_database(self) -> PortMetadataDatabase | None:
|
||||
database = self._port_metadata_database(False)
|
||||
return deepcopy(database) if database is not None else None
|
||||
|
||||
def port_metadata_database(self) -> PortMetadataDatabase:
|
||||
return self.stored_port_metadata_database() or PortMetadataDatabase()
|
||||
|
||||
def port_metadata(self, port_id: PortID) -> PortMetadata:
|
||||
return deepcopy(self.port_metadata_database().ports.get(port_id, PortMetadata()))
|
||||
|
||||
def _set_port_metadata_database(self, database: PortMetadataDatabase | None) -> None:
|
||||
if database is None or not database.ports:
|
||||
if self.model.metadata is not None:
|
||||
self.model.metadata.pop("port_metadata_database", None)
|
||||
else:
|
||||
if self.model.metadata is None:
|
||||
self.model.metadata = {}
|
||||
self.model.metadata["port_metadata_database"] = deepcopy(database)
|
||||
self.port_metadata_database_changed.emit(self.stored_port_metadata_database())
|
||||
|
||||
def _port_metadata_database(self, create: bool) -> PortMetadataDatabase | None:
|
||||
metadata = self.model.metadata
|
||||
value = metadata.get("port_metadata_database") if metadata is not None else None
|
||||
if isinstance(value, PortMetadataDatabase):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
self.model.metadata = metadata
|
||||
database = PortMetadataDatabase()
|
||||
metadata["port_metadata_database"] = database
|
||||
return database
|
||||
|
||||
def move_graph_component(self, graph_component: Component, component_id: ComponentID, position: tuple[int, int]) -> None:
|
||||
graph_id = self.component_id(graph_component)
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
if graph is None or graph.component_positions.get(component_id) != position:
|
||||
self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position))
|
||||
|
||||
def move_graph_components(self, graph_component: Component, positions: dict[ComponentID, tuple[int, int]]) -> None:
|
||||
graph_id = self.component_id(graph_component)
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
changed = {component_id: position for component_id, position in positions.items() if graph is None or graph.component_positions.get(component_id) != position}
|
||||
if not changed:
|
||||
return
|
||||
self.undo_stack.beginMacro("Move graph components" if len(changed) > 1 else "Move graph component")
|
||||
for component_id, position in changed.items():
|
||||
self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position))
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
def _set_graph_component_position(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
|
||||
if position is None:
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
if graph is not None:
|
||||
graph.component_positions.pop(component_id, None)
|
||||
else:
|
||||
database = self._graph_database(True)
|
||||
graph = database.graphs.setdefault(graph_id, Graph())
|
||||
graph.component_positions[component_id] = position
|
||||
self.graph_component_position_changed.emit(graph_id, component_id, position)
|
||||
|
||||
def move_graph_component_label(self, graph_component: Component, component_id: ComponentID, relative_position: tuple[int, int]) -> None:
|
||||
graph_id = self.component_id(graph_component)
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel()
|
||||
if current.relative_position != relative_position:
|
||||
current.relative_position = relative_position
|
||||
self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Move component label"))
|
||||
|
||||
def set_graph_component_label_visible(self, graph_component: Component, component_id: ComponentID, visible: bool) -> None:
|
||||
graph_id = self.component_id(graph_component)
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel()
|
||||
if current.visible != visible:
|
||||
current.visible = visible
|
||||
self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Show component label" if visible else "Hide component label"))
|
||||
|
||||
def _set_graph_component_label(self, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel | None) -> None:
|
||||
if label is None:
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
if graph is not None:
|
||||
graph.component_labels.pop(component_id, None)
|
||||
else:
|
||||
database = self._graph_database(True)
|
||||
graph = database.graphs.setdefault(graph_id, Graph())
|
||||
graph.component_labels[component_id] = deepcopy(label)
|
||||
self.graph_component_label_changed.emit(graph_id, component_id, deepcopy(label))
|
||||
|
||||
def _graph_database(self, create: bool) -> GraphDatabase | None:
|
||||
metadata = self.model.metadata
|
||||
value = metadata.get("graph_database") if metadata is not None else None
|
||||
if isinstance(value, dict):
|
||||
value = GraphDatabase.from_data(value)
|
||||
metadata["graph_database"] = value
|
||||
if isinstance(value, GraphDatabase):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
self.model.metadata = metadata
|
||||
database = GraphDatabase()
|
||||
metadata["graph_database"] = database
|
||||
return database
|
||||
|
||||
def change_graph_connection_points(self, graph_component: Component, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||
graph_id = self.component_id(graph_component)
|
||||
self.undo_stack.push(ChangeGraphConnectionPointsCommand(self, graph_id, connection_id, points, text))
|
||||
|
||||
def add_graph_connection(self, graph_component: Component, connection: Connection) -> None:
|
||||
self.undo_stack.push(AddGraphConnectionCommand(self, graph_component, connection))
|
||||
|
||||
def delete_graph_connections(self, graph_component: Component, connection_ids: list[ConnectionID]) -> None:
|
||||
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||
return
|
||||
connection_ids = [connection_id for connection_id in connection_ids if connection_id in graph_component.implementation.graph.connections]
|
||||
if not connection_ids:
|
||||
return
|
||||
self.undo_stack.beginMacro("Delete connections")
|
||||
for connection_id in connection_ids:
|
||||
self.undo_stack.push(DeleteGraphConnectionCommand(self, graph_component, connection_id))
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
def _set_graph_connection_points(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||
if points is None:
|
||||
database = self._graph_database(False)
|
||||
graph = database.graphs.get(graph_id) if database is not None else None
|
||||
if graph is not None:
|
||||
graph.connections.pop(connection_id, None)
|
||||
else:
|
||||
database = self._graph_database(True)
|
||||
graph = database.graphs.setdefault(graph_id, Graph())
|
||||
graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||
self.graph_connection_points_changed.emit(graph_id, connection_id, points)
|
||||
|
||||
def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
|
||||
self.undo_stack.push(ChangeIconCommand(self, component_id, icon))
|
||||
|
||||
def stored_simulation_database(self) -> SimulationDatabase | None:
|
||||
database = self._simulation_database(False)
|
||||
return deepcopy(database) if database is not None else None
|
||||
|
||||
def simulation_database(self) -> SimulationDatabase:
|
||||
return self.stored_simulation_database() or SimulationDatabase()
|
||||
|
||||
def active_simulation(self) -> Simulation | None:
|
||||
database = self.simulation_database()
|
||||
return database.simulations.get(database.active_simulation) if database.active_simulation is not None else None
|
||||
|
||||
def change_simulation_database(self, database: SimulationDatabase) -> None:
|
||||
if database != self.stored_simulation_database():
|
||||
self.undo_stack.push(ChangeSimulationDatabaseCommand(self, database))
|
||||
|
||||
def _set_simulation_database(self, database: SimulationDatabase | None) -> None:
|
||||
if database is None:
|
||||
if self.model.metadata is not None:
|
||||
self.model.metadata.pop("simulation_database", None)
|
||||
else:
|
||||
if self.model.metadata is None:
|
||||
self.model.metadata = {}
|
||||
self.model.metadata["simulation_database"] = deepcopy(database)
|
||||
self.simulation_database_changed.emit(self.stored_simulation_database())
|
||||
|
||||
def _simulation_database(self, create: bool) -> SimulationDatabase | None:
|
||||
metadata = self.model.metadata
|
||||
value = metadata.get("simulation_database") if metadata is not None else None
|
||||
if isinstance(value, dict):
|
||||
value = SimulationDatabase.from_data(value)
|
||||
metadata["simulation_database"] = value
|
||||
if isinstance(value, SimulationDatabase):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
self.model.metadata = metadata
|
||||
database = SimulationDatabase()
|
||||
metadata["simulation_database"] = database
|
||||
return database
|
||||
|
||||
def _set_component_icon(self, component_id: ComponentID, icon: Icon | None) -> None:
|
||||
if icon is None:
|
||||
database = self._icon_database(False)
|
||||
if database is not None:
|
||||
database.icons.pop(component_id, None)
|
||||
if not database.icons:
|
||||
self.model.metadata.pop("icon_database", None)
|
||||
else:
|
||||
self._icon_database(True).icons[component_id] = deepcopy(icon)
|
||||
self.icon_changed.emit(component_id, self.stored_component_icon(component_id))
|
||||
|
||||
def _icon_database(self, create: bool) -> IconDatabase | None:
|
||||
metadata = self.model.metadata
|
||||
value = metadata.get("icon_database") if metadata is not None else None
|
||||
if isinstance(value, dict):
|
||||
value = IconDatabase.from_data(value)
|
||||
metadata["icon_database"] = value
|
||||
if isinstance(value, IconDatabase):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
self.model.metadata = metadata
|
||||
database = IconDatabase()
|
||||
metadata["icon_database"] = database
|
||||
return database
|
||||
|
||||
def update_component_ports(self, component: Component, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
current = component.interface.ports
|
||||
removed = [
|
||||
RemovePortCommand(self, component, port_id)
|
||||
@@ -105,14 +373,26 @@ class Document(QObject):
|
||||
if current[port_id] != ports[port_id]
|
||||
]
|
||||
commands = [*removed, *added, *changed]
|
||||
if not commands:
|
||||
database = self.port_metadata_database()
|
||||
component_port_ids = set(current) | set(ports)
|
||||
for port_id in component_port_ids:
|
||||
metadata = port_metadata.get(port_id, PortMetadata()) if port_metadata is not None and port_id in ports else PortMetadata()
|
||||
if metadata == PortMetadata():
|
||||
database.ports.pop(port_id, None)
|
||||
else:
|
||||
database.ports[port_id] = deepcopy(metadata)
|
||||
metadata_changed = database != self.port_metadata_database()
|
||||
if not commands and not metadata_changed:
|
||||
return
|
||||
|
||||
self.undo_stack.beginMacro("Edit interface")
|
||||
for command in commands:
|
||||
self.undo_stack.push(command)
|
||||
if metadata_changed:
|
||||
self.undo_stack.push(ChangePortMetadataDatabaseCommand(self, database))
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
|
||||
def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None:
|
||||
current = component.parameters
|
||||
removed = [
|
||||
@@ -137,3 +417,72 @@ class Document(QObject):
|
||||
self.undo_stack.push(command)
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
def update_component_equation_text(self, component: Component, section: str, text: list[str], edit_id: int) -> None:
|
||||
command = ChangeEquationTextCommand(self, component, section, text, edit_id)
|
||||
if command.old_text != command.new_text:
|
||||
self.undo_stack.push(command)
|
||||
|
||||
def add_empty_graph_component(self, component: Component) -> None:
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
command = AddEmptyGraphComponent(self, component)
|
||||
command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
|
||||
self.undo_stack.push(command)
|
||||
|
||||
def add_empty_root_graph_component(self) -> None:
|
||||
command = AddEmptyGraphComponent(self, self.model.root)
|
||||
command.component.name = self._unique_component_name(self.model.root, command.component.name)
|
||||
self.undo_stack.push(command)
|
||||
|
||||
def add_empty_equation_component(self, component: Component) -> None:
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
command = AddEmptyEquationComponent(self, component)
|
||||
command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
|
||||
self.undo_stack.push(command)
|
||||
|
||||
def add_empty_root_equation_component(self) -> None:
|
||||
command = AddEmptyEquationComponent(self, self.model.root)
|
||||
command.component.name = self._unique_component_name(self.model.root, command.component.name)
|
||||
self.undo_stack.push(command)
|
||||
|
||||
def delete_component(self, component: Component) -> None:
|
||||
self.undo_stack.push(DeleteComponent(self, component))
|
||||
|
||||
def delete_components(self, components: list[Component]) -> None:
|
||||
if not components:
|
||||
return
|
||||
self.undo_stack.beginMacro("Delete components")
|
||||
for component in components:
|
||||
self.undo_stack.push(DeleteComponent(self, component))
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
if not components:
|
||||
return
|
||||
names = {component.name for component in target.values()}
|
||||
for component in components.values():
|
||||
component.name = self._unique_name(names, component.name)
|
||||
names.add(component.name)
|
||||
self.undo_stack.push(PasteComponents(self, target, components, icons, port_metadata=port_metadata))
|
||||
|
||||
def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
if not isinstance(graph_component.implementation, GraphImplementation) or not components:
|
||||
return
|
||||
target = graph_component.implementation.graph.components
|
||||
names = {component.name for component in target.values()}
|
||||
for component in components.values():
|
||||
component.name = self._unique_name(names, component.name)
|
||||
names.add(component.name)
|
||||
self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions, port_metadata))
|
||||
|
||||
@staticmethod
|
||||
def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:
|
||||
return Document._unique_name({component.name for component in components.values()}, name)
|
||||
|
||||
@staticmethod
|
||||
def _unique_name(names: set[str], name: str) -> str:
|
||||
if name not in names:
|
||||
return name
|
||||
index = 0
|
||||
while f"{name}_{index}" in names:
|
||||
index += 1
|
||||
return f"{name}_{index}"
|
||||
|
||||
294
src/bedit_gui/models.py
Normal file
294
src/bedit_gui/models.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from bedit_core.models import ComponentID, ConnectionID, ID, PortID
|
||||
|
||||
|
||||
class ShapeID(ID):
|
||||
pass
|
||||
|
||||
class SimulationID(ID):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Shape:
|
||||
layer: int
|
||||
type: str | None = None
|
||||
pos: tuple[int, int] = (0, 0)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Shape:
|
||||
if cls is Shape and data.get("type") == "rectangle":
|
||||
return Rectangle.from_data(data)
|
||||
if cls is Shape and data.get("type") == "ellipse":
|
||||
return Ellipse.from_data(data)
|
||||
if cls is Shape and data.get("type") == "text":
|
||||
return Text.from_data(data)
|
||||
if cls is Shape and data.get("type") == "line":
|
||||
return Line.from_data(data)
|
||||
pos = data.get("pos", [0, 0])
|
||||
return cls(layer=int(data.get("layer", 0)), type=data.get("type"), pos=(int(pos[0]), int(pos[1])))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"layer": self.layer, "type": self.type, "pos": list(self.pos)}
|
||||
|
||||
class LineType(Enum):
|
||||
NONE = "none"
|
||||
SOLID = "solid"
|
||||
DASHED = "dashed"
|
||||
DOTTED = "dotted"
|
||||
DASH_DOT = "dash_dot"
|
||||
|
||||
@dataclass
|
||||
class Line(Shape):
|
||||
type: str = field(init=False, default="line")
|
||||
end: tuple[int, int] = (32, 0)
|
||||
line_type: LineType = LineType.SOLID
|
||||
line_thickness: float = 1.0
|
||||
line_color: str = "#000000ff"
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Line:
|
||||
pos = data.get("pos", [0, 0])
|
||||
end = data.get("end", [32, 0])
|
||||
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), end=(int(end[0]), int(end[1])), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), line_color=str(data.get("line_color", "#000000ff")))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {**super().to_data(), "end": list(self.end), "line_type": self.line_type.value, "line_thickness": self.line_thickness, "line_color": self.line_color}
|
||||
|
||||
@dataclass
|
||||
class Rectangle(Shape):
|
||||
type: str = field(init=False, default="rectangle")
|
||||
width: float = 32.0
|
||||
height: float = 32.0
|
||||
line_type: LineType = LineType.SOLID
|
||||
line_thickness: float = 1.0
|
||||
corner_radius: float = 0.0
|
||||
line_color: str = "#000000ff"
|
||||
fill_color: str = "#ffffff00"
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Rectangle:
|
||||
pos = data.get("pos", [0, 0])
|
||||
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), width=float(data.get("width", 100.0)), height=float(data.get("height", 100.0)), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), corner_radius=float(data.get("corner_radius", 0.0)), line_color=str(data.get("line_color", "#000000ff")), fill_color=str(data.get("fill_color", "#ffffff00")))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "corner_radius": self.corner_radius, "line_color": self.line_color, "fill_color": self.fill_color}
|
||||
|
||||
@dataclass
|
||||
class Ellipse(Shape):
|
||||
type: str = field(init=False, default="ellipse")
|
||||
width: float = 32.0
|
||||
height: float = 32.0
|
||||
line_type: LineType = LineType.SOLID
|
||||
line_thickness: float = 1.0
|
||||
line_color: str = "#000000ff"
|
||||
fill_color: str = "#ffffff00"
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Ellipse:
|
||||
pos = data.get("pos", [0, 0])
|
||||
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), width=float(data.get("width", 100.0)), height=float(data.get("height", 100.0)), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), line_color=str(data.get("line_color", "#000000ff")), fill_color=str(data.get("fill_color", "#ffffff00")))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "line_color": self.line_color, "fill_color": self.fill_color}
|
||||
|
||||
@dataclass
|
||||
class Text(Shape):
|
||||
type: str = field(init=False, default="text")
|
||||
width: float = 32.0
|
||||
height: float = 16.0
|
||||
color: str = "#000000ff"
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
size: float = 16.0
|
||||
text: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Text:
|
||||
pos = data.get("pos", [0, 0])
|
||||
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), width=float(data.get("width", 32.0)), height=float(data.get("height", 16.0)), color=str(data.get("color", "#000000ff")), bold=bool(data.get("bold", False)), italic=bool(data.get("italic", False)), size=float(data.get("size", 16.0)), text=str(data.get("text", "")))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {**super().to_data(), "width": self.width, "height": self.height, "color": self.color, "bold": self.bold, "italic": self.italic, "size": self.size, "text": self.text}
|
||||
|
||||
@dataclass
|
||||
class Icon:
|
||||
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
|
||||
port_positions: dict[PortID, tuple[int, int]] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Icon:
|
||||
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
|
||||
port_positions = {PortID(key): (int(value[0]), int(value[1])) for key, value in data.get("port_positions", {}).items()}
|
||||
return cls(shapes=shapes, port_positions=port_positions)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}, "port_positions": {str(key): list(position) for key, position in self.port_positions.items()}}
|
||||
|
||||
@dataclass
|
||||
class IconDatabase:
|
||||
format_version: int = 1
|
||||
icons: dict[ComponentID, Icon] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> IconDatabase:
|
||||
icons = {ComponentID(key): Icon.from_data(value) for key, value in data.get("icons", {}).items()}
|
||||
return cls(format_version=int(data.get("format_version", 1)), icons=icons)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}}
|
||||
|
||||
@dataclass
|
||||
class PortMetadata:
|
||||
connection_annotation: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> PortMetadata:
|
||||
annotation = data.get("connection_annotation")
|
||||
return cls(connection_annotation=str(annotation) if annotation else None)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"connection_annotation": self.connection_annotation}
|
||||
|
||||
@dataclass
|
||||
class PortMetadataDatabase:
|
||||
format_version: int = 1
|
||||
ports: dict[PortID, PortMetadata] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> PortMetadataDatabase:
|
||||
ports = {PortID(key): PortMetadata.from_data(value) for key, value in data.get("ports", {}).items()}
|
||||
return cls(format_version=int(data.get("format_version", 1)), ports=ports)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"format_version": self.format_version, "ports": {str(key): metadata.to_data() for key, metadata in self.ports.items()}}
|
||||
|
||||
@dataclass
|
||||
class GraphConnection:
|
||||
points: list[tuple[int, int]] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> GraphConnection:
|
||||
return cls(points=[(int(point[0]), int(point[1])) for point in data.get("points", [])])
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"points": [list(point) for point in self.points]}
|
||||
|
||||
@dataclass
|
||||
class GraphComponentLabel:
|
||||
relative_position: tuple[int, int] = (0, 8)
|
||||
visible: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> GraphComponentLabel:
|
||||
position = data.get("relative_position", [0, 8])
|
||||
return cls(relative_position=(int(position[0]), int(position[1])), visible=bool(data.get("visible", True)))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"relative_position": list(self.relative_position), "visible": self.visible}
|
||||
|
||||
@dataclass
|
||||
class Graph:
|
||||
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
|
||||
component_positions: dict[ComponentID, tuple[int, int]] = field(default_factory=dict)
|
||||
connections: dict[ConnectionID, GraphConnection] = field(default_factory=dict)
|
||||
component_labels: dict[ComponentID, GraphComponentLabel] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Graph:
|
||||
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
|
||||
component_positions = {ComponentID(key): (int(value[0]), int(value[1])) for key, value in data.get("component_positions", {}).items()}
|
||||
component_labels = {ComponentID(key): GraphComponentLabel.from_data(value) for key, value in data.get("component_labels", {}).items()}
|
||||
connections = {ConnectionID(key): GraphConnection.from_data(value) for key, value in data.get("connections", {}).items()}
|
||||
return cls(shapes=shapes, component_positions=component_positions, component_labels=component_labels, connections=connections)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()},
|
||||
"component_positions": {str(key): list(position) for key, position in self.component_positions.items()},
|
||||
"component_labels": {str(key): label.to_data() for key, label in self.component_labels.items()},
|
||||
"connections": {str(key): connection.to_data() for key, connection in self.connections.items()},
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class GraphDatabase:
|
||||
format_version: int = 1
|
||||
graphs: dict[ComponentID, Graph] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> GraphDatabase:
|
||||
graphs = {ComponentID(key): Graph.from_data(value) for key, value in data.get("graphs", {}).items()}
|
||||
return cls(format_version=int(data.get("format_version", 1)), graphs=graphs)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"format_version": self.format_version, "graphs": {str(key): graph.to_data() for key, graph in self.graphs.items()}}
|
||||
|
||||
class SimulationMethod(Enum):
|
||||
DASSL = "dassl"
|
||||
|
||||
@dataclass
|
||||
class Simulation:
|
||||
component: ComponentID
|
||||
name: str
|
||||
|
||||
start_time: float
|
||||
duration: float
|
||||
use_timed_steps: bool
|
||||
number_of_steps: int
|
||||
step_size: float
|
||||
|
||||
method: SimulationMethod
|
||||
|
||||
dassl_tolerance: float
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> Simulation:
|
||||
component = data.get("component")
|
||||
use_timed_steps = data.get("use_timed_steps", False)
|
||||
if isinstance(use_timed_steps, str):
|
||||
use_timed_steps = use_timed_steps.lower() == "true"
|
||||
return cls(
|
||||
component=ComponentID(str(component)) if component else ComponentID(),
|
||||
name=str(data.get("name", "")),
|
||||
start_time=float(data.get("start_time", 0.0)),
|
||||
duration=float(data.get("duration", 1.0)),
|
||||
use_timed_steps=bool(use_timed_steps),
|
||||
number_of_steps=int(data.get("number_of_steps", 500)),
|
||||
step_size=float(data.get("step_size", 0.001)),
|
||||
method=SimulationMethod(data.get("method", "dassl")),
|
||||
dassl_tolerance=float(data.get("dassl_tolerance", 1e-6)),
|
||||
)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"component": str(self.component),
|
||||
"name": self.name,
|
||||
"start_time": self.start_time,
|
||||
"duration": self.duration,
|
||||
"use_timed_steps": self.use_timed_steps,
|
||||
"number_of_steps": self.number_of_steps,
|
||||
"step_size": self.step_size,
|
||||
"method": self.method.value,
|
||||
"dassl_tolerance": self.dassl_tolerance,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class SimulationDatabase:
|
||||
format_version: int = 1
|
||||
simulations: dict[SimulationID, Simulation] = field(default_factory=dict)
|
||||
active_simulation: SimulationID | None = None
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationDatabase:
|
||||
sims = {SimulationID(key): Simulation.from_data(value) for key, value in data.get("simulations", {}).items()}
|
||||
active = data.get("active_simulation")
|
||||
active_id = SimulationID(str(active)) if active is not None else None
|
||||
return cls(format_version=int(data.get("format_version", 1)), simulations=sims, active_simulation=active_id if active_id in sims else None)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"format_version": self.format_version, "active_simulation": str(self.active_simulation) if self.active_simulation is not None else None, "simulations": {str(key): sim.to_data() for key, sim in self.simulations.items()}}
|
||||
BIN
src/bedit_gui/resources/icons/dialog-close.png
Normal file
BIN
src/bedit_gui/resources/icons/dialog-close.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/bedit_gui/resources/icons/edit-delete.png
Normal file
BIN
src/bedit_gui/resources/icons/edit-delete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/bedit_gui/resources/icons/media-playback-stop.png
Normal file
BIN
src/bedit_gui/resources/icons/media-playback-stop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/bedit_gui/resources/icons/media-skip-backward.png
Normal file
BIN
src/bedit_gui/resources/icons/media-skip-backward.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,5 +1,9 @@
|
||||
<RCC>
|
||||
<qresource prefix="icons">
|
||||
<file>icons/media-skip-backward.png</file>
|
||||
<file>icons/media-playback-stop.png</file>
|
||||
<file>icons/edit-delete.png</file>
|
||||
<file>icons/dialog-close.png</file>
|
||||
<file>icons/list-remove.png</file>
|
||||
<file>icons/list-add.png</file>
|
||||
<file>icons/view-form-table.png</file>
|
||||
|
||||
@@ -8,6 +8,51 @@ from PySide6.QtCore import QSettings
|
||||
class ApplicationSettings:
|
||||
"""Typed access to persistent BEdit application settings."""
|
||||
|
||||
LOG_LEVEL_KEY = "logging/level"
|
||||
DEFAULT_LOG_LEVEL = logging.INFO
|
||||
SNAP_TO_GRID_SIZE_KEY = "graph/snap_to_grid_size"
|
||||
DEFAULT_SNAP_TO_GRID_SIZE = 4
|
||||
LIBRARY_PATHS_KEY = "libraries/paths"
|
||||
|
||||
def __init__(self, settings: QSettings | None = None) -> None:
|
||||
self._settings = settings if settings is not None else QSettings()
|
||||
|
||||
@property
|
||||
def log_level(self) -> int:
|
||||
return self._settings.value(
|
||||
self.LOG_LEVEL_KEY,
|
||||
self.DEFAULT_LOG_LEVEL,
|
||||
type=int,
|
||||
)
|
||||
|
||||
@log_level.setter
|
||||
def log_level(self, level: int) -> None:
|
||||
self._settings.setValue(self.LOG_LEVEL_KEY, level)
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int:
|
||||
return max(1, self._settings.value(self.SNAP_TO_GRID_SIZE_KEY, self.DEFAULT_SNAP_TO_GRID_SIZE, type=int))
|
||||
|
||||
@snap_to_grid_size.setter
|
||||
def snap_to_grid_size(self, size: int) -> None:
|
||||
if size < 1:
|
||||
raise ValueError("snap-to-grid size must be positive")
|
||||
self._settings.setValue(self.SNAP_TO_GRID_SIZE_KEY, size)
|
||||
|
||||
@property
|
||||
def library_paths(self) -> list[str]:
|
||||
value = self._settings.value(self.LIBRARY_PATHS_KEY, [])
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return [str(path) for path in value] if isinstance(value, (list, tuple)) else []
|
||||
|
||||
@library_paths.setter
|
||||
def library_paths(self, paths: list[str]) -> None:
|
||||
self._settings.setValue(self.LIBRARY_PATHS_KEY, list(dict.fromkeys(paths)))
|
||||
|
||||
class SimulationApplicationSettings:
|
||||
"""Typed access to persistent BEsim application settings."""
|
||||
|
||||
LOG_LEVEL_KEY = "logging/level"
|
||||
DEFAULT_LOG_LEVEL = logging.INFO
|
||||
|
||||
|
||||
44
src/bedit_gui/services/clipboard.py
Normal file
44
src/bedit_gui/services/clipboard.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QMimeData, QObject, Signal
|
||||
from PySide6.QtGui import QClipboard, QGuiApplication
|
||||
|
||||
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
|
||||
|
||||
|
||||
class ClipboardService(QObject):
|
||||
COMPONENTS_MIME = COMPONENTS_MIME
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._clipboard = QGuiApplication.clipboard()
|
||||
self._clipboard.dataChanged.connect(self.changed)
|
||||
|
||||
def set_json(self, mime_type: str, data: dict[str, Any], text: str = "") -> None:
|
||||
mime = QMimeData()
|
||||
mime.setData(mime_type, json.dumps(data).encode("utf-8"))
|
||||
if text:
|
||||
mime.setText(text)
|
||||
self._clipboard.setMimeData(mime, QClipboard.Mode.Clipboard)
|
||||
|
||||
def get_json(self, mime_type: str) -> dict[str, Any] | None:
|
||||
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
|
||||
if mime is None or not mime.hasFormat(mime_type):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(bytes(mime.data(mime_type)).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
def has_format(self, mime_type: str) -> bool:
|
||||
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
|
||||
return mime is not None and mime.hasFormat(mime_type)
|
||||
|
||||
def has_text(self) -> bool:
|
||||
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
|
||||
return mime is not None and mime.hasText()
|
||||
108
src/bedit_gui/services/component_clipboard.py
Normal file
108
src/bedit_gui/services/component_clipboard.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from bedit_core.models import Component, ComponentID, ConnectionID, Document, GraphImplementation, ID, ParameterID, PortID
|
||||
from bedit_core.serialization.schema import document_from_data, document_to_data
|
||||
from bedit_gui.documents import Document as GuiDocument
|
||||
from bedit_gui.models import Icon, PortMetadata, ShapeID
|
||||
|
||||
FORMAT_VERSION = 1
|
||||
COMPONENTS_MIME = "application/x-bedit-components+json"
|
||||
|
||||
|
||||
def export_components(document: GuiDocument, components: list[Component]) -> dict[str, Any]:
|
||||
roots = {document.component_id(component): component for component in components}
|
||||
component_ids = _all_component_ids(roots)
|
||||
icons = {}
|
||||
port_metadata = {}
|
||||
for component_id in component_ids:
|
||||
icon = document.stored_component_icon(component_id)
|
||||
if icon is not None:
|
||||
icons[component_id] = icon
|
||||
database = document.port_metadata_database()
|
||||
for port_id in _all_port_ids(roots):
|
||||
if port_id in database.ports:
|
||||
port_metadata[port_id] = database.ports[port_id]
|
||||
return export_component_data(roots, icons, port_metadata)
|
||||
|
||||
|
||||
def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> dict[str, Any]:
|
||||
serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=components))
|
||||
component_ids = set(_all_component_ids(components))
|
||||
icon_data = {str(component_id): icon.to_data() for component_id, icon in icons.items() if component_id in component_ids}
|
||||
metadata_data = {str(port_id): metadata.to_data() for port_id, metadata in (port_metadata or {}).items() if port_id in set(_all_port_ids(components))}
|
||||
return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data, "port_metadata": metadata_data}
|
||||
|
||||
|
||||
def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon], dict[PortID, PortMetadata]]:
|
||||
if payload.get("format_version") != FORMAT_VERSION or payload.get("type") != "components":
|
||||
raise ValueError("unsupported component clipboard format")
|
||||
components = payload.get("components")
|
||||
if not isinstance(components, dict):
|
||||
raise TypeError("component clipboard payload must contain a components object")
|
||||
clipboard_document = document_from_data({"format_version": 1, "id": str(ID()), "name": "Clipboard", "root": components, "metadata": None})
|
||||
component_map: dict[ComponentID, ComponentID] = {}
|
||||
port_map: dict[PortID, PortID] = {}
|
||||
remapped = _remap_components(clipboard_document.root, component_map, port_map)
|
||||
icon_data = payload.get("icons", {})
|
||||
if not isinstance(icon_data, dict):
|
||||
raise TypeError("component clipboard icons must be an object")
|
||||
icons: dict[ComponentID, Icon] = {}
|
||||
for old_id, data in icon_data.items():
|
||||
new_component_id = component_map.get(ComponentID(old_id))
|
||||
if new_component_id is None or not isinstance(data, dict):
|
||||
continue
|
||||
icon = Icon.from_data(data)
|
||||
icon.shapes = {ShapeID(): shape for shape in icon.shapes.values()}
|
||||
icon.port_positions = {port_map[port_id]: position for port_id, position in icon.port_positions.items() if port_id in port_map}
|
||||
icons[new_component_id] = icon
|
||||
metadata_data = payload.get("port_metadata", {})
|
||||
if not isinstance(metadata_data, dict):
|
||||
raise TypeError("component clipboard port metadata must be an object")
|
||||
port_metadata = {port_map[PortID(old_id)]: PortMetadata.from_data(data) for old_id, data in metadata_data.items() if PortID(old_id) in port_map and isinstance(data, dict)}
|
||||
return remapped, icons, port_metadata
|
||||
|
||||
|
||||
def _remap_components(components: dict[ComponentID, Component], component_map: dict[ComponentID, ComponentID], port_map: dict[PortID, PortID]) -> dict[ComponentID, Component]:
|
||||
remapped: dict[ComponentID, Component] = {}
|
||||
for old_component_id, original in components.items():
|
||||
component = deepcopy(original)
|
||||
new_component_id = ComponentID()
|
||||
component_map[old_component_id] = new_component_id
|
||||
component.interface.ports = {_new_port_id(old_id, port_map): port for old_id, port in component.interface.ports.items()}
|
||||
component.parameters = {ParameterID(): parameter for parameter in component.parameters.values()}
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
graph = component.implementation.graph
|
||||
graph.components = _remap_components(graph.components, component_map, port_map)
|
||||
graph.connections = {ConnectionID(): connection for connection in graph.connections.values()}
|
||||
for connection in graph.connections.values():
|
||||
connection.source = port_map.get(connection.source, connection.source)
|
||||
connection.target = port_map.get(connection.target, connection.target)
|
||||
remapped[new_component_id] = component
|
||||
return remapped
|
||||
|
||||
|
||||
def _new_port_id(old_id: PortID, port_map: dict[PortID, PortID]) -> PortID:
|
||||
new_id = PortID()
|
||||
port_map[old_id] = new_id
|
||||
return new_id
|
||||
|
||||
|
||||
def _all_component_ids(components: dict[ComponentID, Component]) -> list[ComponentID]:
|
||||
component_ids: list[ComponentID] = []
|
||||
for component_id, component in components.items():
|
||||
component_ids.append(component_id)
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
component_ids.extend(_all_component_ids(component.implementation.graph.components))
|
||||
return component_ids
|
||||
|
||||
|
||||
def _all_port_ids(components: dict[ComponentID, Component]) -> list[PortID]:
|
||||
port_ids: list[PortID] = []
|
||||
for component in components.values():
|
||||
port_ids.extend(component.interface.ports)
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
port_ids.extend(_all_port_ids(component.implementation.graph.components))
|
||||
return port_ids
|
||||
@@ -1,17 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from bedit_core.models import Document
|
||||
from bedit_core.serialization import load as load_document
|
||||
from bedit_core.serialization import save as save_document
|
||||
from bedit_gui.models import GraphDatabase, IconDatabase, PortMetadataDatabase, SimulationDatabase
|
||||
|
||||
|
||||
def load(path: str | Path) -> Document:
|
||||
"""Load a supported document file into the core model."""
|
||||
return load_document(path)
|
||||
document = load_document(path)
|
||||
if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict):
|
||||
document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"])
|
||||
if document.metadata is not None and isinstance(document.metadata.get("graph_database"), dict):
|
||||
document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"])
|
||||
if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict):
|
||||
document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"])
|
||||
if document.metadata is not None and isinstance(document.metadata.get("port_metadata_database"), dict):
|
||||
document.metadata["port_metadata_database"] = PortMetadataDatabase.from_data(document.metadata["port_metadata_database"])
|
||||
return document
|
||||
|
||||
|
||||
def save(document: Document, path: str | Path) -> None:
|
||||
"""Save a core model using the format selected by its file extension."""
|
||||
save_document(document, path)
|
||||
saved_document = deepcopy(document)
|
||||
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase):
|
||||
saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data()
|
||||
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("graph_database"), GraphDatabase):
|
||||
saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data()
|
||||
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase):
|
||||
saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data()
|
||||
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("port_metadata_database"), PortMetadataDatabase):
|
||||
saved_document.metadata["port_metadata_database"] = saved_document.metadata["port_metadata_database"].to_data()
|
||||
save_document(saved_document, path)
|
||||
|
||||
17
src/bedit_gui/services/icon_files.py
Normal file
17
src/bedit_gui/services/icon_files.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bedit_gui.models import Icon
|
||||
|
||||
|
||||
def load(path: str | Path) -> Icon:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("icon file must contain a JSON object")
|
||||
return Icon.from_data(data)
|
||||
|
||||
|
||||
def save(icon: Icon, path: str | Path) -> None:
|
||||
Path(path).write_text(json.dumps(icon.to_data(), indent=2) + "\n", encoding="utf-8")
|
||||
42
src/bedit_gui/services/libraries.py
Normal file
42
src/bedit_gui/services/libraries.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from bedit_core.models import Document
|
||||
from bedit_gui.services import document_files
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedLibrary:
|
||||
path: Path
|
||||
document: Document
|
||||
|
||||
|
||||
def list_library_files(library_paths: list[str]) -> list[Path]:
|
||||
files = []
|
||||
seen = set()
|
||||
for configured_path in library_paths:
|
||||
path = Path(configured_path).expanduser()
|
||||
candidates = [path] if path.is_file() else sorted(path.rglob("*"), key=lambda candidate: str(candidate).casefold()) if path.is_dir() else []
|
||||
for candidate in candidates:
|
||||
if not candidate.is_file() or candidate.suffix.lower() not in (".beb", ".json"):
|
||||
continue
|
||||
resolved = candidate.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
files.append(resolved)
|
||||
return files
|
||||
|
||||
|
||||
def load_library_documents(library_paths: list[str]) -> list[LoadedLibrary]:
|
||||
libraries = []
|
||||
for path in list_library_files(library_paths):
|
||||
try:
|
||||
libraries.append(LoadedLibrary(path, document_files.load(path)))
|
||||
except (KeyError, OSError, TypeError, ValueError) as exc:
|
||||
logger.warning("Could not load library %s: %s", path, exc)
|
||||
return libraries
|
||||
79
src/bedit_gui/services/simulation_files.py
Normal file
79
src/bedit_gui/services/simulation_files.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zlib
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import msgpack
|
||||
|
||||
from bedit_gui.simulation_models import SimulationRoot
|
||||
|
||||
BES_MAGIC = b"BES\x00"
|
||||
FILE_FORMAT_VERSION = 1
|
||||
_VERSION_SIZE = 4
|
||||
|
||||
|
||||
def load(path: str | Path) -> SimulationRoot:
|
||||
file_path = Path(path)
|
||||
data = _load_json(file_path) if file_path.suffix.lower() == ".json" else _load_bes(file_path)
|
||||
return SimulationRoot.from_data(data)
|
||||
|
||||
|
||||
def save(root: SimulationRoot, path: str | Path) -> None:
|
||||
file_path = Path(path)
|
||||
if file_path.suffix.lower() == ".json":
|
||||
_save_json(root.to_data(), file_path)
|
||||
elif file_path.suffix.lower() == ".bes":
|
||||
_save_bes(root.to_data(), file_path)
|
||||
else:
|
||||
raise ValueError(f"unsupported simulation file extension {file_path.suffix!r}; expected '.json' or '.bes'")
|
||||
|
||||
|
||||
def is_simulation_json(path: str | Path) -> bool:
|
||||
file_path = Path(path)
|
||||
if file_path.suffix.lower() != ".json":
|
||||
return False
|
||||
try:
|
||||
return _load_json(file_path).get("root_type") == "simulation_root"
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"could not read simulation JSON {path}: {exc}") from exc
|
||||
if not isinstance(data, Mapping):
|
||||
raise TypeError(f"simulation JSON {path} must contain an object")
|
||||
return data
|
||||
|
||||
|
||||
def _save_json(data: Mapping[str, Any], path: Path) -> None:
|
||||
path.write_text(json.dumps({"file_format_version": FILE_FORMAT_VERSION, **data}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _load_bes(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
header_end = len(BES_MAGIC) + _VERSION_SIZE
|
||||
if not payload.startswith(BES_MAGIC):
|
||||
raise ValueError("missing BES file header")
|
||||
if len(payload) < header_end:
|
||||
raise ValueError("truncated BES file header")
|
||||
version = int.from_bytes(payload[len(BES_MAGIC):header_end], "big")
|
||||
if version != FILE_FORMAT_VERSION:
|
||||
raise ValueError(f"unsupported BES file version {version}")
|
||||
data = msgpack.unpackb(zlib.decompress(payload[header_end:]), raw=False, strict_map_key=False)
|
||||
except (OSError, ValueError, zlib.error, msgpack.exceptions.UnpackException) as exc:
|
||||
raise ValueError(f"could not read BES simulation {path}: {exc}") from exc
|
||||
if not isinstance(data, Mapping):
|
||||
raise TypeError(f"BES simulation {path} must contain a map")
|
||||
return data
|
||||
|
||||
|
||||
def _save_bes(data: Mapping[str, Any], path: Path) -> None:
|
||||
encoded = zlib.compress(msgpack.packb(dict(data), use_bin_type=True))
|
||||
path.write_bytes(BES_MAGIC + FILE_FORMAT_VERSION.to_bytes(_VERSION_SIZE, "big") + encoded)
|
||||
72
src/bedit_gui/services/simulation_handoff.py
Normal file
72
src/bedit_gui/services/simulation_handoff.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
|
||||
SERVER_NAME = "bedit-besim-handoff-v1"
|
||||
|
||||
|
||||
def send_simulation_handoff(path: str | Path, compiled_model: CompiledModel, *, server_name: str = SERVER_NAME, timeout_ms: int = 500) -> bool:
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
if not socket.waitForConnected(timeout_ms):
|
||||
return False
|
||||
payload = json.dumps({"path": str(Path(path).resolve()), "compiled_model": compiled_model.to_data()}, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
if socket.write(payload) != len(payload) or not socket.waitForBytesWritten(timeout_ms):
|
||||
socket.abort()
|
||||
return False
|
||||
socket.disconnectFromServer()
|
||||
return True
|
||||
|
||||
|
||||
class SimulationHandoffServer(QObject):
|
||||
handoff_received = Signal(object, object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None, *, server_name: str = SERVER_NAME) -> None:
|
||||
super().__init__(parent)
|
||||
self.server = QLocalServer(self)
|
||||
self._buffers: dict[QLocalSocket, bytearray] = {}
|
||||
self.server.newConnection.connect(self._accept_connections)
|
||||
if not self.server.listen(server_name):
|
||||
probe = QLocalSocket()
|
||||
probe.connectToServer(server_name)
|
||||
if probe.waitForConnected(200):
|
||||
probe.disconnectFromServer()
|
||||
return
|
||||
QLocalServer.removeServer(server_name)
|
||||
if not self.server.listen(server_name):
|
||||
raise RuntimeError(f"could not listen for BEsim handoffs: {self.server.errorString()}")
|
||||
|
||||
def _accept_connections(self) -> None:
|
||||
while self.server.hasPendingConnections():
|
||||
socket = self.server.nextPendingConnection()
|
||||
if socket is None:
|
||||
continue
|
||||
self._buffers[socket] = bytearray()
|
||||
socket.readyRead.connect(lambda active=socket: self._read(active))
|
||||
socket.disconnected.connect(lambda active=socket: self._discard(active))
|
||||
self._read(socket)
|
||||
|
||||
def _read(self, socket: QLocalSocket) -> None:
|
||||
buffer = self._buffers.get(socket)
|
||||
if buffer is None:
|
||||
return
|
||||
buffer.extend(socket.readAll().data())
|
||||
while b"\n" in buffer:
|
||||
raw_message, _, remaining = buffer.partition(b"\n")
|
||||
buffer[:] = remaining
|
||||
try:
|
||||
message = json.loads(raw_message.decode("utf-8"))
|
||||
path = Path(str(message["path"]))
|
||||
compiled_model = CompiledModel.from_data(message["compiled_model"])
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
self.handoff_received.emit(path, compiled_model)
|
||||
|
||||
def _discard(self, socket: QLocalSocket) -> None:
|
||||
self._buffers.pop(socket, None)
|
||||
95
src/bedit_gui/services/simulation_loader.py
Normal file
95
src/bedit_gui/services/simulation_loader.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from bedit_core.models import Component, ComponentID, Document, GraphImplementation
|
||||
from bedit_gui.models import Simulation, SimulationMethod
|
||||
from bedit_gui.services import document_files
|
||||
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
|
||||
from bedit_simulation import compile_component_sync
|
||||
|
||||
|
||||
def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]:
|
||||
choices: list[tuple[ComponentID, Component, str]] = []
|
||||
|
||||
def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None:
|
||||
for component_id, component in items.items():
|
||||
component_path = (*path, component.name)
|
||||
choices.append((component_id, component, ".".join(component_path)))
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
collect(component.implementation.graph.components, component_path)
|
||||
|
||||
collect(document.root)
|
||||
return choices
|
||||
|
||||
|
||||
def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc") -> tuple[SimulationRoot, CompiledModel]:
|
||||
"""Open a BEdit document, resolve one launch target, and compile it."""
|
||||
if bool(component_selector) == bool(simulation_selector):
|
||||
raise ValueError("specify exactly one component or simulation settings block")
|
||||
source_path = Path(path).resolve()
|
||||
document = document_files.load(source_path)
|
||||
choices = component_choices(document)
|
||||
settings_name: str | None = None
|
||||
|
||||
if component_selector is not None:
|
||||
component_id, component, component_path = _find_component(choices, component_selector)
|
||||
settings = _default_settings(component_id)
|
||||
else:
|
||||
database = document.metadata.get("simulation_database") if document.metadata is not None else None
|
||||
if database is None or not hasattr(database, "simulations"):
|
||||
raise ValueError("the BEdit document does not contain simulation settings")
|
||||
matches = [(simulation_id, simulation) for simulation_id, simulation in database.simulations.items() if simulation.name == simulation_selector or str(simulation_id) == simulation_selector]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"simulation settings {simulation_selector!r} were not found or are ambiguous")
|
||||
_simulation_id, settings = matches[0]
|
||||
component_id, component, component_path = _find_component(choices, str(settings.component))
|
||||
settings_name = settings.name
|
||||
|
||||
build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
|
||||
root = SimulationRoot(
|
||||
format_version=1,
|
||||
source_document=str(source_path),
|
||||
source_document_id=str(document.id),
|
||||
component=component_id,
|
||||
component_path=component_path,
|
||||
settings_name=settings_name,
|
||||
settings=settings,
|
||||
)
|
||||
return root, _compile(component, build_directory, omc_command)
|
||||
|
||||
|
||||
def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc") -> CompiledModel:
|
||||
if root.source_document is None:
|
||||
raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled")
|
||||
document = document_files.load(root.source_document)
|
||||
_component_id, component, _component_path = _find_component(component_choices(document), str(root.component))
|
||||
build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
|
||||
return _compile(component, build_directory, omc_command)
|
||||
|
||||
|
||||
def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]:
|
||||
matches = [choice for choice in choices if str(choice[0]) == selector or choice[2] == selector]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"component {selector!r} was not found or is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _default_settings(component_id: ComponentID) -> Simulation:
|
||||
return Simulation(
|
||||
component=component_id,
|
||||
name="Default",
|
||||
start_time=0.0,
|
||||
duration=1.0,
|
||||
use_timed_steps=False,
|
||||
number_of_steps=500,
|
||||
step_size=0.002,
|
||||
method=SimulationMethod.DASSL,
|
||||
dassl_tolerance=1e-6,
|
||||
)
|
||||
|
||||
|
||||
def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel:
|
||||
build = compile_component_sync(component, working_directory, omc_command=omc_command)
|
||||
return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors)
|
||||
70
src/bedit_gui/simulation_application.py
Normal file
70
src/bedit_gui/simulation_application.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
|
||||
from bedit_gui.controllers.log_controller import LogController
|
||||
from bedit_gui.controllers.simulation_plot_controller import SimulationPlotController
|
||||
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
|
||||
from bedit_gui.controllers.simulation_handoff_controller import SimulationHandoffController
|
||||
from bedit_gui.simulation_models import CompiledModel
|
||||
from bedit_gui.services.application_settings import SimulationApplicationSettings
|
||||
from bedit_gui.views.simulation_window import SimulationWindow
|
||||
from bedit_gui.versions import BESIM_VERSION
|
||||
|
||||
|
||||
def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Open and run BEdit simulations")
|
||||
parser.add_argument("--version", action="version", version=f"BEsim {BESIM_VERSION}")
|
||||
parser.add_argument("file", nargs="?", help="BEdit (.beb/.json) or simulation (.bes/.json) file")
|
||||
parser.add_argument("-f", "--file", dest="file_option", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--handoff", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--model-name", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--executable", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--working-directory", help=argparse.SUPPRESS)
|
||||
target = parser.add_mutually_exclusive_group()
|
||||
target.add_argument("-c", "--component", help="Component ID or dotted path in a BEdit file")
|
||||
target.add_argument("-s", "--simulation", help="Simulation settings name or ID in a BEdit file")
|
||||
args = parser.parse_args(arguments)
|
||||
args.file = args.file_option or args.file
|
||||
if args.handoff and not all((args.model_name, args.executable, args.working_directory)):
|
||||
parser.error("a simulator handoff requires compiled-model arguments")
|
||||
return args
|
||||
|
||||
|
||||
def main(arguments: list[str] | None = None) -> int:
|
||||
args = parse_arguments(arguments)
|
||||
app = QApplication(sys.argv if arguments is None else [sys.argv[0], *arguments])
|
||||
|
||||
app.setOrganizationName("BEsim")
|
||||
app.setApplicationName("BEsim")
|
||||
app.setApplicationVersion(BESIM_VERSION)
|
||||
|
||||
window = SimulationWindow()
|
||||
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}"))
|
||||
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
|
||||
controller = SimulationFileController(window)
|
||||
|
||||
settings = SimulationApplicationSettings()
|
||||
LogController(window, settings.log_level)
|
||||
run_controller = SimulationRunController(window, controller)
|
||||
plot_controller = SimulationPlotController(window, controller)
|
||||
SimulationHandoffController(window, controller, run_controller)
|
||||
run_controller.simulation_state_changed.connect(plot_controller.refresh_results)
|
||||
|
||||
window.showMaximized()
|
||||
|
||||
if args.file:
|
||||
try:
|
||||
compiled_model = CompiledModel(args.model_name, args.executable, args.working_directory) if args.handoff else None
|
||||
controller.open(args.file, component=args.component, simulation=args.simulation, backed_by_file=not args.handoff, compiled_model=compiled_model)
|
||||
except (OSError, ValueError, RuntimeError) as exc:
|
||||
QMessageBox.critical(window, "Could not open simulation", str(exc))
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
168
src/bedit_gui/simulation_models.py
Normal file
168
src/bedit_gui/simulation_models.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
from bedit_gui.models import Simulation
|
||||
from bedit_simulation import SimulationResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompiledModel:
|
||||
model_name: str
|
||||
executable: str
|
||||
working_directory: str
|
||||
output: str = ""
|
||||
errors: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> CompiledModel:
|
||||
return cls(model_name=str(data["model_name"]), executable=str(data["executable"]), working_directory=str(data["working_directory"]), output=str(data.get("output", "")), errors=str(data.get("errors", "")))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory, "output": self.output, "errors": self.errors}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationTraceSettings:
|
||||
visible: bool = True
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
line_style: str = "-"
|
||||
line_width: float = 1.5
|
||||
marker: str = ""
|
||||
marker_size: float = 6.0
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationTraceSettings:
|
||||
return cls(visible=bool(data.get("visible", True)), label=str(data.get("label", "")), color=str(data.get("color", "")), line_style=str(data.get("line_style", "-")), line_width=float(data.get("line_width", 1.5)), marker=str(data.get("marker", "")), marker_size=float(data.get("marker_size", 6.0)))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"visible": self.visible, "label": self.label, "color": self.color, "line_style": self.line_style, "line_width": self.line_width, "marker": self.marker, "marker_size": self.marker_size}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationPlotSettings:
|
||||
title: str = ""
|
||||
x_label: str = ""
|
||||
y_label: str = ""
|
||||
x_scale: str = "linear"
|
||||
y_scale: str = "linear"
|
||||
x_auto: bool = True
|
||||
y_auto: bool = True
|
||||
x_min: float = 0.0
|
||||
x_max: float = 1.0
|
||||
y_min: float = 0.0
|
||||
y_max: float = 1.0
|
||||
grid_visible: bool = True
|
||||
grid_axis: str = "both"
|
||||
grid_style: str = "-"
|
||||
grid_alpha: float = 0.5
|
||||
legend_visible: bool = True
|
||||
legend_location: str = "best"
|
||||
traces: dict[str, SimulationTraceSettings] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotSettings:
|
||||
raw_traces = data.get("traces", {})
|
||||
if not isinstance(raw_traces, Mapping):
|
||||
raise TypeError("plot trace settings must be a mapping")
|
||||
return cls(
|
||||
title=str(data.get("title", "")), x_label=str(data.get("x_label", "")), y_label=str(data.get("y_label", "")),
|
||||
x_scale=str(data.get("x_scale", "linear")), y_scale=str(data.get("y_scale", "linear")),
|
||||
x_auto=bool(data.get("x_auto", True)), y_auto=bool(data.get("y_auto", True)),
|
||||
x_min=float(data.get("x_min", 0.0)), x_max=float(data.get("x_max", 1.0)), y_min=float(data.get("y_min", 0.0)), y_max=float(data.get("y_max", 1.0)),
|
||||
grid_visible=bool(data.get("grid_visible", True)), grid_axis=str(data.get("grid_axis", "both")), grid_style=str(data.get("grid_style", "-")), grid_alpha=float(data.get("grid_alpha", 0.5)),
|
||||
legend_visible=bool(data.get("legend_visible", True)), legend_location=str(data.get("legend_location", "best")),
|
||||
traces={str(signal): SimulationTraceSettings.from_data(trace) for signal, trace in raw_traces.items() if isinstance(trace, Mapping)},
|
||||
)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"title": self.title, "x_label": self.x_label, "y_label": self.y_label, "x_scale": self.x_scale, "y_scale": self.y_scale,
|
||||
"x_auto": self.x_auto, "y_auto": self.y_auto, "x_min": self.x_min, "x_max": self.x_max, "y_min": self.y_min, "y_max": self.y_max,
|
||||
"grid_visible": self.grid_visible, "grid_axis": self.grid_axis, "grid_style": self.grid_style, "grid_alpha": self.grid_alpha,
|
||||
"legend_visible": self.legend_visible, "legend_location": self.legend_location,
|
||||
"traces": {signal: trace.to_data() for signal, trace in self.traces.items()},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationPlotTab:
|
||||
name: str
|
||||
signals: list[str] = field(default_factory=list)
|
||||
x_axis: str | None = None
|
||||
settings: SimulationPlotSettings = field(default_factory=SimulationPlotSettings)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotTab:
|
||||
raw_signals = data.get("signals", [])
|
||||
if not isinstance(raw_signals, list):
|
||||
raise TypeError("plot tab signals must be a list")
|
||||
raw_settings = data.get("settings", {})
|
||||
if not isinstance(raw_settings, Mapping):
|
||||
raise TypeError("plot tab settings must be a mapping")
|
||||
return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals], x_axis=str(data["x_axis"]) if data.get("x_axis") is not None else None, settings=SimulationPlotSettings.from_data(raw_settings))
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {"name": self.name, "signals": self.signals, "x_axis": self.x_axis, "settings": self.settings.to_data()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationRoot:
|
||||
format_version: int
|
||||
source_document: str | None
|
||||
source_document_id: str | None
|
||||
component: ComponentID
|
||||
component_path: str
|
||||
settings_name: str | None
|
||||
settings: Simulation
|
||||
current_end_time: float | None = None
|
||||
results: list[SimulationResult] = field(default_factory=list)
|
||||
plot_tabs: list[SimulationPlotTab] = field(default_factory=lambda: [SimulationPlotTab("Plot 1")])
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot:
|
||||
if data.get("root_type") != "simulation_root":
|
||||
raise ValueError("file does not contain a simulation root")
|
||||
return cls(
|
||||
format_version=int(data.get("format_version", 1)),
|
||||
source_document=str(data["source_document"]) if data.get("source_document") is not None else None,
|
||||
source_document_id=str(data["source_document_id"]) if data.get("source_document_id") is not None else None,
|
||||
component=ComponentID(str(data["component"])),
|
||||
component_path=str(data.get("component_path", "")),
|
||||
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
|
||||
settings=Simulation.from_data(data["settings"]),
|
||||
current_end_time=float(data["current_end_time"]) if data.get("current_end_time") is not None else None,
|
||||
results=[_result_from_data(result) for result in data.get("results", [])],
|
||||
plot_tabs=[SimulationPlotTab.from_data(tab) for tab in data["plot_tabs"]] if "plot_tabs" in data else [SimulationPlotTab("Plot 1")],
|
||||
)
|
||||
|
||||
def to_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"root_type": "simulation_root",
|
||||
"format_version": self.format_version,
|
||||
"source_document": self.source_document,
|
||||
"source_document_id": self.source_document_id,
|
||||
"component": str(self.component),
|
||||
"component_path": self.component_path,
|
||||
"settings_name": self.settings_name,
|
||||
"settings": self.settings.to_data(),
|
||||
"current_end_time": self.current_end_time,
|
||||
"results": [_result_to_data(result) for result in self.results],
|
||||
"plot_tabs": [tab.to_data() for tab in self.plot_tabs],
|
||||
}
|
||||
|
||||
|
||||
def _result_from_data(data: Mapping[str, Any]) -> SimulationResult:
|
||||
raw_columns = data.get("data", {})
|
||||
if not isinstance(raw_columns, Mapping):
|
||||
raise TypeError("simulation result data must be a mapping")
|
||||
columns = {str(name): [float(value) for value in values] for name, values in raw_columns.items()}
|
||||
return SimulationResult(model_name=str(data.get("model_name", "")), data=columns, process_output=str(data.get("process_output", "")), process_errors=str(data.get("process_errors", "")))
|
||||
|
||||
|
||||
def _result_to_data(result: SimulationResult) -> dict[str, Any]:
|
||||
return {"model_name": result.model_name, "data": result.data, "process_output": result.process_output, "process_errors": result.process_errors}
|
||||
86
src/bedit_gui/ui/forms/equation_editor_widget.ui
Normal file
86
src/bedit_gui/ui/forms/equation_editor_widget.ui
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>equationEditorWidget</class>
|
||||
<widget class="QWidget" name="equationEditorWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1079</width>
|
||||
<height>730</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="declarationEditor">
|
||||
<property name="text">
|
||||
<string>Declarations</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="declarationsTextEdit"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="initialEquationEditor">
|
||||
<property name="text">
|
||||
<string>Initial equations</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="initialEquationsTextEdit"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="equationEditor">
|
||||
<property name="text">
|
||||
<string>Equations</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="equationsTextEdit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item alignment="Qt::AlignmentFlag::AlignLeft">
|
||||
<widget class="QToolButton" name="sidebarButton">
|
||||
<property name="toolTip">
|
||||
<string>Hide parameter and port editors</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::ArrowType::RightArrow</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="paramEditor">
|
||||
<property name="text">
|
||||
<string>paramEditor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="portEditor">
|
||||
<property name="text">
|
||||
<string>portEditor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
109
src/bedit_gui/ui/forms/graph_editor_widget.ui
Normal file
109
src/bedit_gui/ui/forms/graph_editor_widget.ui
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>graphEditorWidget</class>
|
||||
<widget class="QWidget" name="graphEditorWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1079</width>
|
||||
<height>730</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Graph Editor</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolBar" name="graphToolBar">
|
||||
<property name="windowTitle">
|
||||
<string>Graph tools</string>
|
||||
</property>
|
||||
<property name="movable">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="floatable">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<addaction name="actionZoomToFit"/>
|
||||
<addaction name="actionMouseMode"/>
|
||||
<addaction name="actionConnectionMode"/>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGraphicsView" name="graphicsView">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::NoFrame</enum>
|
||||
</property>
|
||||
<property name="renderHints">
|
||||
<set>QPainter::RenderHint::Antialiasing</set>
|
||||
</property>
|
||||
<property name="dragMode">
|
||||
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
<action name="actionZoomToFit">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/zoom-original.png</normaloff>:/icons/icons/zoom-original.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Zoom to Fit</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Zoom canvas to fit</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionMouseMode">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-select.png</normaloff>:/icons/icons/edit-select.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Mouse Mode</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Mouse Mode</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionConnectionMode">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/network-connect.png</normaloff>:/icons/icons/network-connect.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Connection Mode</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Connection Mode</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../resources/resources.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
90
src/bedit_gui/ui/forms/graph_editor_widget_ui.py
Normal file
90
src/bedit_gui/ui/forms/graph_editor_widget_ui.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'graph_editor_widget.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.11.1
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QFrame, QGraphicsView, QSizePolicy,
|
||||
QToolBar, QVBoxLayout, QWidget)
|
||||
import resources_rc
|
||||
|
||||
class Ui_graphEditorWidget(object):
|
||||
def setupUi(self, graphEditorWidget):
|
||||
if not graphEditorWidget.objectName():
|
||||
graphEditorWidget.setObjectName(u"graphEditorWidget")
|
||||
graphEditorWidget.resize(1079, 730)
|
||||
self.actionZoomToFit = QAction(graphEditorWidget)
|
||||
self.actionZoomToFit.setObjectName(u"actionZoomToFit")
|
||||
icon = QIcon()
|
||||
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionZoomToFit.setIcon(icon)
|
||||
self.actionMouseMode = QAction(graphEditorWidget)
|
||||
self.actionMouseMode.setObjectName(u"actionMouseMode")
|
||||
self.actionMouseMode.setCheckable(True)
|
||||
icon1 = QIcon()
|
||||
icon1.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionMouseMode.setIcon(icon1)
|
||||
self.actionConnectionMode = QAction(graphEditorWidget)
|
||||
self.actionConnectionMode.setObjectName(u"actionConnectionMode")
|
||||
self.actionConnectionMode.setCheckable(True)
|
||||
icon2 = QIcon()
|
||||
icon2.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionConnectionMode.setIcon(icon2)
|
||||
self.verticalLayout = QVBoxLayout(graphEditorWidget)
|
||||
self.verticalLayout.setSpacing(0)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.graphToolBar = QToolBar(graphEditorWidget)
|
||||
self.graphToolBar.setObjectName(u"graphToolBar")
|
||||
self.graphToolBar.setMovable(False)
|
||||
self.graphToolBar.setFloatable(False)
|
||||
|
||||
self.verticalLayout.addWidget(self.graphToolBar)
|
||||
|
||||
self.graphicsView = QGraphicsView(graphEditorWidget)
|
||||
self.graphicsView.setObjectName(u"graphicsView")
|
||||
self.graphicsView.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.graphicsView.setRenderHints(QPainter.RenderHint.Antialiasing)
|
||||
self.graphicsView.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||
|
||||
self.verticalLayout.addWidget(self.graphicsView)
|
||||
|
||||
|
||||
self.graphToolBar.addAction(self.actionZoomToFit)
|
||||
self.graphToolBar.addAction(self.actionMouseMode)
|
||||
self.graphToolBar.addAction(self.actionConnectionMode)
|
||||
|
||||
self.retranslateUi(graphEditorWidget)
|
||||
|
||||
QMetaObject.connectSlotsByName(graphEditorWidget)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, graphEditorWidget):
|
||||
graphEditorWidget.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph Editor", None))
|
||||
self.actionZoomToFit.setText(QCoreApplication.translate("graphEditorWidget", u"Zoom to Fit", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionMouseMode.setText(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionMouseMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionConnectionMode.setText(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionConnectionMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
|
||||
# retranslateUi
|
||||
|
||||
260
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal file
260
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal file
@@ -0,0 +1,260 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>iconEditor</class>
|
||||
<widget class="QMainWindow" name="iconEditor">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGraphicsView" name="graphicsView"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>19</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuEdit">
|
||||
<property name="title">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>File</string>
|
||||
</property>
|
||||
<addaction name="actionOpen_from_File"/>
|
||||
<addaction name="actionSave_to_File"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionCancel"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuEdit"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QToolBar" name="actionToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionCancel"/>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="iconToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionAdd_Rectangle"/>
|
||||
<addaction name="actionAdd_Circle"/>
|
||||
<addaction name="actionAdd_Text"/>
|
||||
<addaction name="actionAdd_Line"/>
|
||||
</widget>
|
||||
<action name="actionUndo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Undo</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Undo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Z</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRedo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Redo</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Redo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Y</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Save icon</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Return</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCancel">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/dialog-close.png</normaloff>:/icons/icons/dialog-close.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Cancel</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Cancel icon editing</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Shift+Esc</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_Rectangle">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-rectangle.png</normaloff>:/icons/icons/draw-rectangle.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add Rectangle</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Add a rectangle</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_Text">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-text.png</normaloff>:/icons/icons/draw-text.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add Text</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Add a text field</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_to_File">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-save-as.png</normaloff>:/icons/icons/document-save-as.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save to File</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Save icon to a file</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Shift+S</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpen_from_File">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Open from File</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Open icon from File</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Shift+O</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_Line">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add Line</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Add a line</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_Circle">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-circle.png</normaloff>:/icons/icons/draw-circle.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add Ellipse</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Add a circle</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../resources/resources.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
206
src/bedit_gui/ui/forms/icon_editor_window_ui.py
Normal file
206
src/bedit_gui/ui/forms/icon_editor_window_ui.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'icon_editor_window.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.11.1
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QGraphicsView, QMainWindow, QMenu,
|
||||
QMenuBar, QSizePolicy, QStatusBar, QToolBar,
|
||||
QVBoxLayout, QWidget)
|
||||
import resources_rc
|
||||
|
||||
class Ui_iconEditor(object):
|
||||
def setupUi(self, iconEditor):
|
||||
if not iconEditor.objectName():
|
||||
iconEditor.setObjectName(u"iconEditor")
|
||||
iconEditor.resize(800, 600)
|
||||
icon = QIcon()
|
||||
icon.addFile(u":/icons/icons/draw-path.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
iconEditor.setWindowIcon(icon)
|
||||
self.actionUndo = QAction(iconEditor)
|
||||
self.actionUndo.setObjectName(u"actionUndo")
|
||||
icon1 = QIcon()
|
||||
icon1.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionUndo.setIcon(icon1)
|
||||
self.actionUndo.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionRedo = QAction(iconEditor)
|
||||
self.actionRedo.setObjectName(u"actionRedo")
|
||||
icon2 = QIcon()
|
||||
icon2.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionRedo.setIcon(icon2)
|
||||
self.actionRedo.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionSave = QAction(iconEditor)
|
||||
self.actionSave.setObjectName(u"actionSave")
|
||||
icon3 = QIcon()
|
||||
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionSave.setIcon(icon3)
|
||||
self.actionSave.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionCancel = QAction(iconEditor)
|
||||
self.actionCancel.setObjectName(u"actionCancel")
|
||||
icon4 = QIcon()
|
||||
icon4.addFile(u":/icons/icons/dialog-close.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionCancel.setIcon(icon4)
|
||||
self.actionCancel.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAdd_Rectangle = QAction(iconEditor)
|
||||
self.actionAdd_Rectangle.setObjectName(u"actionAdd_Rectangle")
|
||||
icon5 = QIcon()
|
||||
icon5.addFile(u":/icons/icons/draw-rectangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionAdd_Rectangle.setIcon(icon5)
|
||||
self.actionAdd_Rectangle.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAdd_Text = QAction(iconEditor)
|
||||
self.actionAdd_Text.setObjectName(u"actionAdd_Text")
|
||||
icon6 = QIcon()
|
||||
icon6.addFile(u":/icons/icons/draw-text.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionAdd_Text.setIcon(icon6)
|
||||
self.actionAdd_Text.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionSave_to_File = QAction(iconEditor)
|
||||
self.actionSave_to_File.setObjectName(u"actionSave_to_File")
|
||||
icon7 = QIcon()
|
||||
icon7.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionSave_to_File.setIcon(icon7)
|
||||
self.actionSave_to_File.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionOpen_from_File = QAction(iconEditor)
|
||||
self.actionOpen_from_File.setObjectName(u"actionOpen_from_File")
|
||||
icon8 = QIcon()
|
||||
icon8.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionOpen_from_File.setIcon(icon8)
|
||||
self.actionOpen_from_File.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAdd_Line = QAction(iconEditor)
|
||||
self.actionAdd_Line.setObjectName(u"actionAdd_Line")
|
||||
self.actionAdd_Line.setIcon(icon)
|
||||
self.actionAdd_Line.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAdd_Circle = QAction(iconEditor)
|
||||
self.actionAdd_Circle.setObjectName(u"actionAdd_Circle")
|
||||
icon9 = QIcon()
|
||||
icon9.addFile(u":/icons/icons/draw-circle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionAdd_Circle.setIcon(icon9)
|
||||
self.actionAdd_Circle.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.centralwidget = QWidget(iconEditor)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.verticalLayout = QVBoxLayout(self.centralwidget)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.graphicsView = QGraphicsView(self.centralwidget)
|
||||
self.graphicsView.setObjectName(u"graphicsView")
|
||||
|
||||
self.verticalLayout.addWidget(self.graphicsView)
|
||||
|
||||
iconEditor.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(iconEditor)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 800, 19))
|
||||
self.menuEdit = QMenu(self.menubar)
|
||||
self.menuEdit.setObjectName(u"menuEdit")
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
iconEditor.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(iconEditor)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
iconEditor.setStatusBar(self.statusbar)
|
||||
self.actionToolbar = QToolBar(iconEditor)
|
||||
self.actionToolbar.setObjectName(u"actionToolbar")
|
||||
iconEditor.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.actionToolbar)
|
||||
self.iconToolbar = QToolBar(iconEditor)
|
||||
self.iconToolbar.setObjectName(u"iconToolbar")
|
||||
iconEditor.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.iconToolbar)
|
||||
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuEdit.menuAction())
|
||||
self.menuEdit.addAction(self.actionUndo)
|
||||
self.menuEdit.addAction(self.actionRedo)
|
||||
self.menuFile.addAction(self.actionOpen_from_File)
|
||||
self.menuFile.addAction(self.actionSave_to_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionSave)
|
||||
self.menuFile.addAction(self.actionCancel)
|
||||
self.actionToolbar.addAction(self.actionUndo)
|
||||
self.actionToolbar.addAction(self.actionRedo)
|
||||
self.actionToolbar.addAction(self.actionSave)
|
||||
self.actionToolbar.addAction(self.actionCancel)
|
||||
self.iconToolbar.addAction(self.actionAdd_Rectangle)
|
||||
self.iconToolbar.addAction(self.actionAdd_Circle)
|
||||
self.iconToolbar.addAction(self.actionAdd_Text)
|
||||
self.iconToolbar.addAction(self.actionAdd_Line)
|
||||
|
||||
self.retranslateUi(iconEditor)
|
||||
|
||||
QMetaObject.connectSlotsByName(iconEditor)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, iconEditor):
|
||||
iconEditor.setWindowTitle(QCoreApplication.translate("iconEditor", u"MainWindow", None))
|
||||
self.actionUndo.setText(QCoreApplication.translate("iconEditor", u"Undo", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionUndo.setToolTip(QCoreApplication.translate("iconEditor", u"Undo", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionUndo.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Z", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionRedo.setText(QCoreApplication.translate("iconEditor", u"Redo", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionRedo.setToolTip(QCoreApplication.translate("iconEditor", u"Redo", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionRedo.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Y", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave.setText(QCoreApplication.translate("iconEditor", u"Save", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionSave.setToolTip(QCoreApplication.translate("iconEditor", u"Save icon", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave.setShortcut(QCoreApplication.translate("iconEditor", u"Return", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionCancel.setText(QCoreApplication.translate("iconEditor", u"Cancel", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionCancel.setToolTip(QCoreApplication.translate("iconEditor", u"Cancel icon editing", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionCancel.setShortcut(QCoreApplication.translate("iconEditor", u"Shift+Esc", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionAdd_Rectangle.setText(QCoreApplication.translate("iconEditor", u"Add Rectangle", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionAdd_Rectangle.setToolTip(QCoreApplication.translate("iconEditor", u"Add a rectangle", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionAdd_Text.setText(QCoreApplication.translate("iconEditor", u"Add Text", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionAdd_Text.setToolTip(QCoreApplication.translate("iconEditor", u"Add a text field", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionSave_to_File.setText(QCoreApplication.translate("iconEditor", u"Save to File", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionSave_to_File.setToolTip(QCoreApplication.translate("iconEditor", u"Save icon to a file", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_to_File.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_from_File.setText(QCoreApplication.translate("iconEditor", u"Open from File", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionOpen_from_File.setToolTip(QCoreApplication.translate("iconEditor", u"Open icon from File", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_from_File.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Shift+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionAdd_Line.setText(QCoreApplication.translate("iconEditor", u"Add Line", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionAdd_Line.setToolTip(QCoreApplication.translate("iconEditor", u"Add a line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionAdd_Circle.setText(QCoreApplication.translate("iconEditor", u"Add Ellipse", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionAdd_Circle.setToolTip(QCoreApplication.translate("iconEditor", u"Add a circle", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.menuEdit.setTitle(QCoreApplication.translate("iconEditor", u"Edit", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("iconEditor", u"File", None))
|
||||
self.actionToolbar.setWindowTitle(QCoreApplication.translate("iconEditor", u"toolBar", None))
|
||||
self.iconToolbar.setWindowTitle(QCoreApplication.translate("iconEditor", u"toolBar", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>940</width>
|
||||
<height>22</height>
|
||||
<height>19</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
@@ -52,7 +52,14 @@
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionCopy"/>
|
||||
<addaction name="actionCut"/>
|
||||
<addaction name="actionPaste"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionDelete"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSettings"/>
|
||||
<addaction name="actionReload_Libraries"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuView">
|
||||
<property name="title">
|
||||
@@ -66,11 +73,23 @@
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="actionAbout"/>
|
||||
<addaction name="actionAbout_QT"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuSimulation">
|
||||
<property name="title">
|
||||
<string>Simulation</string>
|
||||
</property>
|
||||
<addaction name="actionSimulation_Settings"/>
|
||||
<addaction name="actionEdit_Parameters"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionCompile_Model"/>
|
||||
<addaction name="actionOpen_Simulation_Window"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuEdit"/>
|
||||
<addaction name="menuView"/>
|
||||
<addaction name="menuSimulation"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
@@ -101,12 +120,15 @@
|
||||
</attribute>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="actionCopy"/>
|
||||
<addaction name="actionCut"/>
|
||||
<addaction name="actionPaste"/>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="documentTreeWidget">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>533</height>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@@ -144,6 +166,42 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="simToolBar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionSimulation_Settings"/>
|
||||
<addaction name="actionEdit_Parameters"/>
|
||||
<addaction name="actionCompile_Model"/>
|
||||
<addaction name="actionOpen_Simulation_Window"/>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="libraryWidget">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Libraries</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>1</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_4">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QTreeView" name="libraryTree"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="actionOpen_File">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
@@ -294,6 +352,153 @@
|
||||
<string>Settings</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionReload_Libraries">
|
||||
<property name="text">
|
||||
<string>Reload Libraries</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Reload configured libraries</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionDelete">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-delete.png</normaloff>:/icons/icons/edit-delete.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Delete</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Delete selected</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Del</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionEscape">
|
||||
<property name="text">
|
||||
<string>Escape</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Esc</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCopy">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-copy.png</normaloff>:/icons/icons/edit-copy.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Copy</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Copy selected</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+C</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionPaste">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-paste.png</normaloff>:/icons/icons/edit-paste.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Paste</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Paste selected</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+V</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCut">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-cut.png</normaloff>:/icons/icons/edit-cut.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Cut</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Cut selected</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+X</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSimulation_Settings">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/configure.png</normaloff>:/icons/icons/configure.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Simulation Settings</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionEdit_Parameters">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/view-form-table.png</normaloff>:/icons/icons/view-form-table.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Edit Parameters</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCompile_Model">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/run-build.png</normaloff>:/icons/icons/run-build.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Compile Model</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpen_Simulation_Window">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/office-chart-line.png</normaloff>:/icons/icons/office-chart-line.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Open Simulation Window</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="text">
|
||||
<string>About</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../resources/resources.qrc"/>
|
||||
|
||||
370
src/bedit_gui/ui/forms/main_window_ui.py
Normal file
370
src/bedit_gui/ui/forms/main_window_ui.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'main_window.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.11.1
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QDockWidget, QHeaderView, QListView,
|
||||
QMainWindow, QMenu, QMenuBar, QSizePolicy,
|
||||
QStatusBar, QTabWidget, QToolBar, QTreeView,
|
||||
QVBoxLayout, QWidget)
|
||||
import resources_rc
|
||||
|
||||
class Ui_MainWindow(object):
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
MainWindow.resize(940, 729)
|
||||
icon = QIcon()
|
||||
icon.addFile(u":/icons/icons/office-chart-line.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
MainWindow.setWindowIcon(icon)
|
||||
MainWindow.setDocumentMode(False)
|
||||
MainWindow.setTabShape(QTabWidget.TabShape.Triangular)
|
||||
self.actionOpen_File = QAction(MainWindow)
|
||||
self.actionOpen_File.setObjectName(u"actionOpen_File")
|
||||
icon1 = QIcon()
|
||||
icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionOpen_File.setIcon(icon1)
|
||||
self.actionOpen_File.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionNew_File = QAction(MainWindow)
|
||||
self.actionNew_File.setObjectName(u"actionNew_File")
|
||||
icon2 = QIcon()
|
||||
icon2.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionNew_File.setIcon(icon2)
|
||||
self.actionNew_File.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionSave_File = QAction(MainWindow)
|
||||
self.actionSave_File.setObjectName(u"actionSave_File")
|
||||
icon3 = QIcon()
|
||||
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionSave_File.setIcon(icon3)
|
||||
self.actionSave_File.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionSave_File_As = QAction(MainWindow)
|
||||
self.actionSave_File_As.setObjectName(u"actionSave_File_As")
|
||||
icon4 = QIcon()
|
||||
icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionSave_File_As.setIcon(icon4)
|
||||
self.actionSave_File_As.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionClose = QAction(MainWindow)
|
||||
self.actionClose.setObjectName(u"actionClose")
|
||||
self.actionClose.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAbout_QT = QAction(MainWindow)
|
||||
self.actionAbout_QT.setObjectName(u"actionAbout_QT")
|
||||
self.actionAbout_QT.setMenuRole(QAction.MenuRole.AboutQtRole)
|
||||
self.actionUndo = QAction(MainWindow)
|
||||
self.actionUndo.setObjectName(u"actionUndo")
|
||||
icon5 = QIcon()
|
||||
icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionUndo.setIcon(icon5)
|
||||
self.actionUndo.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionRedo = QAction(MainWindow)
|
||||
self.actionRedo.setObjectName(u"actionRedo")
|
||||
icon6 = QIcon()
|
||||
icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionRedo.setIcon(icon6)
|
||||
self.actionRedo.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionReset_Layout = QAction(MainWindow)
|
||||
self.actionReset_Layout.setObjectName(u"actionReset_Layout")
|
||||
self.actionReset_Layout.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionPanels = QAction(MainWindow)
|
||||
self.actionPanels.setObjectName(u"actionPanels")
|
||||
self.actionToolbars = QAction(MainWindow)
|
||||
self.actionToolbars.setObjectName(u"actionToolbars")
|
||||
self.actionSettings = QAction(MainWindow)
|
||||
self.actionSettings.setObjectName(u"actionSettings")
|
||||
self.actionReload_Libraries = QAction(MainWindow)
|
||||
self.actionReload_Libraries.setObjectName(u"actionReload_Libraries")
|
||||
self.actionReload_Libraries.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionDelete = QAction(MainWindow)
|
||||
self.actionDelete.setObjectName(u"actionDelete")
|
||||
icon7 = QIcon()
|
||||
icon7.addFile(u":/icons/icons/edit-delete.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionDelete.setIcon(icon7)
|
||||
self.actionDelete.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionEscape = QAction(MainWindow)
|
||||
self.actionEscape.setObjectName(u"actionEscape")
|
||||
self.actionEscape.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionCopy = QAction(MainWindow)
|
||||
self.actionCopy.setObjectName(u"actionCopy")
|
||||
icon8 = QIcon()
|
||||
icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionCopy.setIcon(icon8)
|
||||
self.actionCopy.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionPaste = QAction(MainWindow)
|
||||
self.actionPaste.setObjectName(u"actionPaste")
|
||||
icon9 = QIcon()
|
||||
icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionPaste.setIcon(icon9)
|
||||
self.actionPaste.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionCut = QAction(MainWindow)
|
||||
self.actionCut.setObjectName(u"actionCut")
|
||||
icon10 = QIcon()
|
||||
icon10.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionCut.setIcon(icon10)
|
||||
self.actionCut.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionSimulation_Settings = QAction(MainWindow)
|
||||
self.actionSimulation_Settings.setObjectName(u"actionSimulation_Settings")
|
||||
icon11 = QIcon()
|
||||
icon11.addFile(u":/icons/icons/configure.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionSimulation_Settings.setIcon(icon11)
|
||||
self.actionSimulation_Settings.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionEdit_Parameters = QAction(MainWindow)
|
||||
self.actionEdit_Parameters.setObjectName(u"actionEdit_Parameters")
|
||||
icon12 = QIcon()
|
||||
icon12.addFile(u":/icons/icons/view-form-table.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionEdit_Parameters.setIcon(icon12)
|
||||
self.actionEdit_Parameters.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionCompile_Model = QAction(MainWindow)
|
||||
self.actionCompile_Model.setObjectName(u"actionCompile_Model")
|
||||
icon13 = QIcon()
|
||||
icon13.addFile(u":/icons/icons/run-build.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.actionCompile_Model.setIcon(icon13)
|
||||
self.actionCompile_Model.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionOpen_Simulation_Window = QAction(MainWindow)
|
||||
self.actionOpen_Simulation_Window.setObjectName(u"actionOpen_Simulation_Window")
|
||||
self.actionOpen_Simulation_Window.setIcon(icon)
|
||||
self.actionOpen_Simulation_Window.setMenuRole(QAction.MenuRole.NoRole)
|
||||
self.actionAbout = QAction(MainWindow)
|
||||
self.actionAbout.setObjectName(u"actionAbout")
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
MainWindow.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(MainWindow)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 940, 19))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuEdit = QMenu(self.menubar)
|
||||
self.menuEdit.setObjectName(u"menuEdit")
|
||||
self.menuView = QMenu(self.menubar)
|
||||
self.menuView.setObjectName(u"menuView")
|
||||
self.menuHelp = QMenu(self.menubar)
|
||||
self.menuHelp.setObjectName(u"menuHelp")
|
||||
self.menuSimulation = QMenu(self.menubar)
|
||||
self.menuSimulation.setObjectName(u"menuSimulation")
|
||||
MainWindow.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(MainWindow)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
self.fileToolBar = QToolBar(MainWindow)
|
||||
self.fileToolBar.setObjectName(u"fileToolBar")
|
||||
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.fileToolBar)
|
||||
self.undoToolBar = QToolBar(MainWindow)
|
||||
self.undoToolBar.setObjectName(u"undoToolBar")
|
||||
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.undoToolBar)
|
||||
self.documentTreeWidget = QDockWidget(MainWindow)
|
||||
self.documentTreeWidget.setObjectName(u"documentTreeWidget")
|
||||
self.documentTreeWidget.setMinimumSize(QSize(150, 200))
|
||||
self.dockWidgetContents = QWidget()
|
||||
self.dockWidgetContents.setObjectName(u"dockWidgetContents")
|
||||
self.verticalLayout = QVBoxLayout(self.dockWidgetContents)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.documentTree = QTreeView(self.dockWidgetContents)
|
||||
self.documentTree.setObjectName(u"documentTree")
|
||||
|
||||
self.verticalLayout.addWidget(self.documentTree)
|
||||
|
||||
self.documentTreeWidget.setWidget(self.dockWidgetContents)
|
||||
MainWindow.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.documentTreeWidget)
|
||||
self.logWidget = QDockWidget(MainWindow)
|
||||
self.logWidget.setObjectName(u"logWidget")
|
||||
self.logWidget.setMinimumSize(QSize(150, 107))
|
||||
self.dockWidgetContents_5 = QWidget()
|
||||
self.dockWidgetContents_5.setObjectName(u"dockWidgetContents_5")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.dockWidgetContents_5)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.listView = QListView(self.dockWidgetContents_5)
|
||||
self.listView.setObjectName(u"listView")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.listView)
|
||||
|
||||
self.logWidget.setWidget(self.dockWidgetContents_5)
|
||||
MainWindow.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.logWidget)
|
||||
self.simToolBar = QToolBar(MainWindow)
|
||||
self.simToolBar.setObjectName(u"simToolBar")
|
||||
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.simToolBar)
|
||||
self.libraryWidget = QDockWidget(MainWindow)
|
||||
self.libraryWidget.setObjectName(u"libraryWidget")
|
||||
self.libraryWidget.setMinimumSize(QSize(150, 200))
|
||||
self.dockWidgetContents_4 = QWidget()
|
||||
self.dockWidgetContents_4.setObjectName(u"dockWidgetContents_4")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.dockWidgetContents_4)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.libraryTree = QTreeView(self.dockWidgetContents_4)
|
||||
self.libraryTree.setObjectName(u"libraryTree")
|
||||
|
||||
self.verticalLayout_4.addWidget(self.libraryTree)
|
||||
|
||||
self.libraryWidget.setWidget(self.dockWidgetContents_4)
|
||||
MainWindow.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.libraryWidget)
|
||||
|
||||
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_File)
|
||||
self.menuFile.addAction(self.actionOpen_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionSave_File)
|
||||
self.menuFile.addAction(self.actionSave_File_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionClose)
|
||||
self.menuEdit.addAction(self.actionUndo)
|
||||
self.menuEdit.addAction(self.actionRedo)
|
||||
self.menuEdit.addSeparator()
|
||||
self.menuEdit.addAction(self.actionCopy)
|
||||
self.menuEdit.addAction(self.actionCut)
|
||||
self.menuEdit.addAction(self.actionPaste)
|
||||
self.menuEdit.addSeparator()
|
||||
self.menuEdit.addAction(self.actionDelete)
|
||||
self.menuEdit.addSeparator()
|
||||
self.menuEdit.addAction(self.actionSettings)
|
||||
self.menuEdit.addAction(self.actionReload_Libraries)
|
||||
self.menuView.addAction(self.actionReset_Layout)
|
||||
self.menuView.addAction(self.actionPanels)
|
||||
self.menuView.addAction(self.actionToolbars)
|
||||
self.menuHelp.addAction(self.actionAbout)
|
||||
self.menuHelp.addAction(self.actionAbout_QT)
|
||||
self.menuSimulation.addAction(self.actionSimulation_Settings)
|
||||
self.menuSimulation.addAction(self.actionEdit_Parameters)
|
||||
self.menuSimulation.addSeparator()
|
||||
self.menuSimulation.addAction(self.actionCompile_Model)
|
||||
self.menuSimulation.addAction(self.actionOpen_Simulation_Window)
|
||||
self.fileToolBar.addAction(self.actionNew_File)
|
||||
self.fileToolBar.addAction(self.actionOpen_File)
|
||||
self.fileToolBar.addAction(self.actionSave_File)
|
||||
self.fileToolBar.addAction(self.actionSave_File_As)
|
||||
self.undoToolBar.addAction(self.actionUndo)
|
||||
self.undoToolBar.addAction(self.actionRedo)
|
||||
self.undoToolBar.addAction(self.actionCopy)
|
||||
self.undoToolBar.addAction(self.actionCut)
|
||||
self.undoToolBar.addAction(self.actionPaste)
|
||||
self.simToolBar.addAction(self.actionSimulation_Settings)
|
||||
self.simToolBar.addAction(self.actionEdit_Parameters)
|
||||
self.simToolBar.addAction(self.actionCompile_Model)
|
||||
self.simToolBar.addAction(self.actionOpen_Simulation_Window)
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
|
||||
self.actionOpen_File.setText(QCoreApplication.translate("MainWindow", u"Open File", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionOpen_File.setToolTip(QCoreApplication.translate("MainWindow", u"Open a file", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionNew_File.setText(QCoreApplication.translate("MainWindow", u"New File", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionNew_File.setToolTip(QCoreApplication.translate("MainWindow", u"Create a new file", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_File.setText(QCoreApplication.translate("MainWindow", u"Save File", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionSave_File.setToolTip(QCoreApplication.translate("MainWindow", u"Save a file to disk", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_File_As.setText(QCoreApplication.translate("MainWindow", u"Save File As...", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionSave_File_As.setToolTip(QCoreApplication.translate("MainWindow", u"Save file to disk as another file", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_File_As.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionClose.setText(QCoreApplication.translate("MainWindow", u"Close", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionClose.setToolTip(QCoreApplication.translate("MainWindow", u"Close application", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionAbout_QT.setText(QCoreApplication.translate("MainWindow", u"About QT", None))
|
||||
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"Undo", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionUndo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Z", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionRedo.setText(QCoreApplication.translate("MainWindow", u"Redo", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionRedo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Y", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionReset_Layout.setText(QCoreApplication.translate("MainWindow", u"Reset Layout", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionReset_Layout.setToolTip(QCoreApplication.translate("MainWindow", u"Reset window layout to default", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionPanels.setText(QCoreApplication.translate("MainWindow", u"Panels", None))
|
||||
self.actionToolbars.setText(QCoreApplication.translate("MainWindow", u"Toolbars", None))
|
||||
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"Settings", None))
|
||||
self.actionReload_Libraries.setText(QCoreApplication.translate("MainWindow", u"Reload Libraries", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionReload_Libraries.setToolTip(QCoreApplication.translate("MainWindow", u"Reload configured libraries", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"Delete", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionDelete.setToolTip(QCoreApplication.translate("MainWindow", u"Delete selected", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionEscape.setText(QCoreApplication.translate("MainWindow", u"Escape", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionEscape.setShortcut(QCoreApplication.translate("MainWindow", u"Esc", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionCopy.setText(QCoreApplication.translate("MainWindow", u"Copy", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionCopy.setToolTip(QCoreApplication.translate("MainWindow", u"Copy selected", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionCopy.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+C", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionPaste.setText(QCoreApplication.translate("MainWindow", u"Paste", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionPaste.setToolTip(QCoreApplication.translate("MainWindow", u"Paste selected", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionPaste.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+V", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionCut.setText(QCoreApplication.translate("MainWindow", u"Cut", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.actionCut.setToolTip(QCoreApplication.translate("MainWindow", u"Cut selected", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionCut.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+X", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSimulation_Settings.setText(QCoreApplication.translate("MainWindow", u"Simulation Settings", None))
|
||||
self.actionEdit_Parameters.setText(QCoreApplication.translate("MainWindow", u"Edit Parameters", None))
|
||||
self.actionCompile_Model.setText(QCoreApplication.translate("MainWindow", u"Compile Model", None))
|
||||
self.actionOpen_Simulation_Window.setText(QCoreApplication.translate("MainWindow", u"Open Simulation Window", None))
|
||||
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"About", 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.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"Help", None))
|
||||
self.menuSimulation.setTitle(QCoreApplication.translate("MainWindow", u"Simulation", None))
|
||||
self.fileToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
|
||||
self.undoToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
|
||||
self.documentTreeWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document Tree", None))
|
||||
self.logWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Log", None))
|
||||
self.simToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
|
||||
self.libraryWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None))
|
||||
# retranslateUi
|
||||
|
||||
102
src/bedit_gui/ui/forms/plot_settings.ui
Normal file
102
src/bedit_gui/ui/forms/plot_settings.ui
Normal file
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>PlotSettingsDialog</class>
|
||||
<widget class="QDialog" name="PlotSettingsDialog">
|
||||
<property name="geometry"><rect><x>0</x><y>0</y><width>520</width><height>430</height></rect></property>
|
||||
<property name="windowTitle"><string>Plot Settings</string></property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex"><number>0</number></property>
|
||||
<widget class="QWidget" name="generalTab">
|
||||
<attribute name="title"><string>General</string></attribute>
|
||||
<layout class="QVBoxLayout" name="generalLayout">
|
||||
<item><widget class="QGroupBox" name="labelsGroup"><property name="title"><string>Labels</string></property><layout class="QFormLayout" name="labelsForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="titleLabel"><property name="text"><string>Title:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QLineEdit" name="titleEdit"><property name="placeholderText"><string>Optional plot title</string></property></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="xLabel"><property name="text"><string>X-axis label:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QLineEdit" name="xLabelEdit"><property name="placeholderText"><string>Automatic</string></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="yLabel"><property name="text"><string>Y-axis label:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QLineEdit" name="yLabelEdit"><property name="placeholderText"><string>Optional</string></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="generalSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>40</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="axesTab">
|
||||
<attribute name="title"><string>Axes</string></attribute>
|
||||
<layout class="QVBoxLayout" name="axesLayout">
|
||||
<item><widget class="QGroupBox" name="xAxisGroup"><property name="title"><string>X axis</string></property><layout class="QGridLayout" name="xAxisLayout">
|
||||
<item row="0" column="0"><widget class="QLabel" name="xScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="xScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
|
||||
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="xAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="xMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QDoubleSpinBox" name="xMinSpin"><property name="decimals"><number>8</number></property></widget></item>
|
||||
<item row="2" column="2"><widget class="QLabel" name="xMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
|
||||
<item row="2" column="3"><widget class="QDoubleSpinBox" name="xMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><widget class="QGroupBox" name="yAxisGroup"><property name="title"><string>Y axis</string></property><layout class="QGridLayout" name="yAxisLayout">
|
||||
<item row="0" column="0"><widget class="QLabel" name="yScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="yScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
|
||||
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="yAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="yMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QDoubleSpinBox" name="yMinSpin"><property name="decimals"><number>8</number></property></widget></item>
|
||||
<item row="2" column="2"><widget class="QLabel" name="yMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
|
||||
<item row="2" column="3"><widget class="QDoubleSpinBox" name="yMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="axesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tracesTab">
|
||||
<attribute name="title"><string>Traces</string></attribute>
|
||||
<layout class="QVBoxLayout" name="tracesLayout">
|
||||
<item><layout class="QFormLayout" name="traceSelectionForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="traceLabel"><property name="text"><string>Trace:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="traceCombo"/></item>
|
||||
</layout></item>
|
||||
<item><widget class="QGroupBox" name="traceGroup"><property name="title"><string>Appearance</string></property><layout class="QFormLayout" name="traceForm">
|
||||
<item row="0" column="0" colspan="2"><widget class="QCheckBox" name="traceVisibleCheck"><property name="text"><string>Visible</string></property><property name="checked"><bool>true</bool></property></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="traceLegendLabel"><property name="text"><string>Legend label:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QLineEdit" name="traceLegendEdit"><property name="placeholderText"><string>Signal name</string></property></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="traceColorLabel"><property name="text"><string>Line color:</string></property></widget></item>
|
||||
<item row="2" column="1"><layout class="QHBoxLayout" name="traceColorLayout"><item><widget class="QPushButton" name="traceColorButton"><property name="text"><string>Automatic</string></property></widget></item><item><widget class="QPushButton" name="traceColorResetButton"><property name="text"><string>Reset</string></property></widget></item></layout></item>
|
||||
<item row="3" column="0"><widget class="QLabel" name="traceLineStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
|
||||
<item row="3" column="1"><widget class="QComboBox" name="traceLineStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item><item><property name="text"><string>No line</string></property></item></widget></item>
|
||||
<item row="4" column="0"><widget class="QLabel" name="traceLineWidthLabel"><property name="text"><string>Line width:</string></property></widget></item>
|
||||
<item row="4" column="1"><widget class="QDoubleSpinBox" name="traceLineWidthSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>20.000000000000000</double></property><property name="singleStep"><double>0.250000000000000</double></property><property name="value"><double>1.500000000000000</double></property></widget></item>
|
||||
<item row="5" column="0"><widget class="QLabel" name="traceMarkerLabel"><property name="text"><string>Marker:</string></property></widget></item>
|
||||
<item row="5" column="1"><widget class="QComboBox" name="traceMarkerCombo"/></item>
|
||||
<item row="6" column="0"><widget class="QLabel" name="traceMarkerSizeLabel"><property name="text"><string>Marker size:</string></property></widget></item>
|
||||
<item row="6" column="1"><widget class="QDoubleSpinBox" name="traceMarkerSizeSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>50.000000000000000</double></property><property name="singleStep"><double>0.500000000000000</double></property><property name="value"><double>6.000000000000000</double></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="tracesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="appearanceTab">
|
||||
<attribute name="title"><string>Grid & Legend</string></attribute>
|
||||
<layout class="QVBoxLayout" name="appearanceLayout">
|
||||
<item><widget class="QGroupBox" name="gridGroup"><property name="title"><string>Grid</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="gridForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="gridAxisLabel"><property name="text"><string>Grid lines:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="gridAxisCombo"><item><property name="text"><string>Both axes</string></property></item><item><property name="text"><string>X axis only</string></property></item><item><property name="text"><string>Y axis only</string></property></item></widget></item>
|
||||
<item row="1" column="0"><widget class="QLabel" name="gridStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
|
||||
<item row="1" column="1"><widget class="QComboBox" name="gridStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item></widget></item>
|
||||
<item row="2" column="0"><widget class="QLabel" name="gridOpacityLabel"><property name="text"><string>Opacity:</string></property></widget></item>
|
||||
<item row="2" column="1"><widget class="QSlider" name="gridOpacitySlider"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="maximum"><number>100</number></property><property name="value"><number>50</number></property></widget></item>
|
||||
</layout></widget></item>
|
||||
<item><widget class="QGroupBox" name="legendGroup"><property name="title"><string>Legend</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="legendForm">
|
||||
<item row="0" column="0"><widget class="QLabel" name="legendLocationLabel"><property name="text"><string>Position:</string></property></widget></item>
|
||||
<item row="0" column="1"><widget class="QComboBox" name="legendLocationCombo"/></item>
|
||||
</layout></widget></item>
|
||||
<item><spacer name="appearanceSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>30</height></size></property></spacer></item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>PlotSettingsDialog</receiver><slot>accept()</slot><hints/></connection>
|
||||
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>PlotSettingsDialog</receiver><slot>reject()</slot><hints/></connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -148,6 +148,20 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="connectionAnnotationLabel">
|
||||
<property name="text">
|
||||
<string>Connection annotation:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QLineEdit" name="connectionAnnotationEdit">
|
||||
<property name="placeholderText">
|
||||
<string>For example, + or -</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
268
src/bedit_gui/ui/forms/port_editor_widget_ui.py
Normal file
268
src/bedit_gui/ui/forms/port_editor_widget_ui.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'port_editor_widget.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, QCheckBox, QComboBox, QFormLayout,
|
||||
QFrame, QHBoxLayout, QLabel, QLineEdit,
|
||||
QListView, QPlainTextEdit, QPushButton, QRadioButton,
|
||||
QSizePolicy, QSpacerItem, QSpinBox, QVBoxLayout,
|
||||
QWidget)
|
||||
|
||||
class Ui_PortEditor(object):
|
||||
def setupUi(self, PortEditor):
|
||||
if not PortEditor.objectName():
|
||||
PortEditor.setObjectName(u"PortEditor")
|
||||
PortEditor.resize(541, 420)
|
||||
self.horizontalLayout_2 = QHBoxLayout(PortEditor)
|
||||
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||
self.leftColumn = QVBoxLayout()
|
||||
self.leftColumn.setObjectName(u"leftColumn")
|
||||
self.portList = QListView(PortEditor)
|
||||
self.portList.setObjectName(u"portList")
|
||||
|
||||
self.leftColumn.addWidget(self.portList)
|
||||
|
||||
self.buttonRow = QHBoxLayout()
|
||||
self.buttonRow.setObjectName(u"buttonRow")
|
||||
self.addPort = QPushButton(PortEditor)
|
||||
self.addPort.setObjectName(u"addPort")
|
||||
|
||||
self.buttonRow.addWidget(self.addPort)
|
||||
|
||||
self.removePort = QPushButton(PortEditor)
|
||||
self.removePort.setObjectName(u"removePort")
|
||||
|
||||
self.buttonRow.addWidget(self.removePort)
|
||||
|
||||
|
||||
self.leftColumn.addLayout(self.buttonRow)
|
||||
|
||||
|
||||
self.horizontalLayout_2.addLayout(self.leftColumn)
|
||||
|
||||
self.rightColumn = QVBoxLayout()
|
||||
self.rightColumn.setObjectName(u"rightColumn")
|
||||
self.basicForm = QFormLayout()
|
||||
self.basicForm.setObjectName(u"basicForm")
|
||||
self.nameLabel = QLabel(PortEditor)
|
||||
self.nameLabel.setObjectName(u"nameLabel")
|
||||
|
||||
self.basicForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
|
||||
|
||||
self.nameEdit = QLineEdit(PortEditor)
|
||||
self.nameEdit.setObjectName(u"nameEdit")
|
||||
|
||||
self.basicForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
|
||||
|
||||
self.typeLabel = QLabel(PortEditor)
|
||||
self.typeLabel.setObjectName(u"typeLabel")
|
||||
|
||||
self.basicForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel)
|
||||
|
||||
self.typeRow = QHBoxLayout()
|
||||
self.typeRow.setObjectName(u"typeRow")
|
||||
self.typeSignal = QRadioButton(PortEditor)
|
||||
self.typeSignal.setObjectName(u"typeSignal")
|
||||
|
||||
self.typeRow.addWidget(self.typeSignal)
|
||||
|
||||
self.typeBond = QRadioButton(PortEditor)
|
||||
self.typeBond.setObjectName(u"typeBond")
|
||||
|
||||
self.typeRow.addWidget(self.typeBond)
|
||||
|
||||
|
||||
self.basicForm.setLayout(1, QFormLayout.ItemRole.FieldRole, self.typeRow)
|
||||
|
||||
self.orientationLabel = QLabel(PortEditor)
|
||||
self.orientationLabel.setObjectName(u"orientationLabel")
|
||||
|
||||
self.basicForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.orientationLabel)
|
||||
|
||||
self.orientationRow = QHBoxLayout()
|
||||
self.orientationRow.setObjectName(u"orientationRow")
|
||||
self.inputOrientation = QRadioButton(PortEditor)
|
||||
self.inputOrientation.setObjectName(u"inputOrientation")
|
||||
|
||||
self.orientationRow.addWidget(self.inputOrientation)
|
||||
|
||||
self.outputOrientation = QRadioButton(PortEditor)
|
||||
self.outputOrientation.setObjectName(u"outputOrientation")
|
||||
|
||||
self.orientationRow.addWidget(self.outputOrientation)
|
||||
|
||||
|
||||
self.basicForm.setLayout(2, QFormLayout.ItemRole.FieldRole, self.orientationRow)
|
||||
|
||||
self.sizeLabel = QLabel(PortEditor)
|
||||
self.sizeLabel.setObjectName(u"sizeLabel")
|
||||
|
||||
self.basicForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.sizeLabel)
|
||||
|
||||
self.sizeRow = QHBoxLayout()
|
||||
self.sizeRow.setObjectName(u"sizeRow")
|
||||
self.widthSize = QSpinBox(PortEditor)
|
||||
self.widthSize.setObjectName(u"widthSize")
|
||||
self.widthSize.setMinimum(1)
|
||||
|
||||
self.sizeRow.addWidget(self.widthSize)
|
||||
|
||||
self.heightSize = QSpinBox(PortEditor)
|
||||
self.heightSize.setObjectName(u"heightSize")
|
||||
self.heightSize.setMinimum(1)
|
||||
|
||||
self.sizeRow.addWidget(self.heightSize)
|
||||
|
||||
|
||||
self.basicForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.sizeRow)
|
||||
|
||||
self.domainLabel = QLabel(PortEditor)
|
||||
self.domainLabel.setObjectName(u"domainLabel")
|
||||
|
||||
self.basicForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.domainLabel)
|
||||
|
||||
self.multiplicityCheckBox = QCheckBox(PortEditor)
|
||||
self.multiplicityCheckBox.setObjectName(u"multiplicityCheckBox")
|
||||
|
||||
self.basicForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.multiplicityCheckBox)
|
||||
|
||||
self.connectionAnnotationLabel = QLabel(PortEditor)
|
||||
self.connectionAnnotationLabel.setObjectName(u"connectionAnnotationLabel")
|
||||
|
||||
self.basicForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.connectionAnnotationLabel)
|
||||
|
||||
self.connectionAnnotationEdit = QLineEdit(PortEditor)
|
||||
self.connectionAnnotationEdit.setObjectName(u"connectionAnnotationEdit")
|
||||
|
||||
self.basicForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.connectionAnnotationEdit)
|
||||
|
||||
|
||||
self.rightColumn.addLayout(self.basicForm)
|
||||
|
||||
self.line = QFrame(PortEditor)
|
||||
self.line.setObjectName(u"line")
|
||||
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.rightColumn.addWidget(self.line)
|
||||
|
||||
self.signalOptions = QFormLayout()
|
||||
self.signalOptions.setObjectName(u"signalOptions")
|
||||
self.signalTypeLabel = QLabel(PortEditor)
|
||||
self.signalTypeLabel.setObjectName(u"signalTypeLabel")
|
||||
|
||||
self.signalOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.signalTypeLabel)
|
||||
|
||||
self.signalTypeComboBox = QComboBox(PortEditor)
|
||||
self.signalTypeComboBox.setObjectName(u"signalTypeComboBox")
|
||||
|
||||
self.signalOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.signalTypeComboBox)
|
||||
|
||||
|
||||
self.rightColumn.addLayout(self.signalOptions)
|
||||
|
||||
self.bondOptions = QFormLayout()
|
||||
self.bondOptions.setObjectName(u"bondOptions")
|
||||
self.domainLabel_2 = QLabel(PortEditor)
|
||||
self.domainLabel_2.setObjectName(u"domainLabel_2")
|
||||
|
||||
self.bondOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel_2)
|
||||
|
||||
self.domainComboBox = QComboBox(PortEditor)
|
||||
self.domainComboBox.setObjectName(u"domainComboBox")
|
||||
|
||||
self.bondOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainComboBox)
|
||||
|
||||
self.causalityLabel = QLabel(PortEditor)
|
||||
self.causalityLabel.setObjectName(u"causalityLabel")
|
||||
|
||||
self.bondOptions.setWidget(1, QFormLayout.ItemRole.LabelRole, self.causalityLabel)
|
||||
|
||||
self.causalityComboBox = QComboBox(PortEditor)
|
||||
self.causalityComboBox.setObjectName(u"causalityComboBox")
|
||||
|
||||
self.bondOptions.setWidget(1, QFormLayout.ItemRole.FieldRole, self.causalityComboBox)
|
||||
|
||||
|
||||
self.rightColumn.addLayout(self.bondOptions)
|
||||
|
||||
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
|
||||
|
||||
self.rightColumn.addItem(self.verticalSpacer)
|
||||
|
||||
self.line_2 = QFrame(PortEditor)
|
||||
self.line_2.setObjectName(u"line_2")
|
||||
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.rightColumn.addWidget(self.line_2)
|
||||
|
||||
self.descriptionForm = QFormLayout()
|
||||
self.descriptionForm.setObjectName(u"descriptionForm")
|
||||
self.descriptionForm.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.descriptionLabel = QLabel(PortEditor)
|
||||
self.descriptionLabel.setObjectName(u"descriptionLabel")
|
||||
|
||||
self.descriptionForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
|
||||
|
||||
self.descriptionEdit = QPlainTextEdit(PortEditor)
|
||||
self.descriptionEdit.setObjectName(u"descriptionEdit")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.descriptionEdit.sizePolicy().hasHeightForWidth())
|
||||
self.descriptionEdit.setSizePolicy(sizePolicy)
|
||||
self.descriptionEdit.setMinimumSize(QSize(0, 20))
|
||||
self.descriptionEdit.setMaximumSize(QSize(16777215, 60))
|
||||
|
||||
self.descriptionForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
|
||||
|
||||
|
||||
self.rightColumn.addLayout(self.descriptionForm)
|
||||
|
||||
|
||||
self.horizontalLayout_2.addLayout(self.rightColumn)
|
||||
|
||||
|
||||
self.retranslateUi(PortEditor)
|
||||
|
||||
QMetaObject.connectSlotsByName(PortEditor)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, PortEditor):
|
||||
PortEditor.setWindowTitle(QCoreApplication.translate("PortEditor", u"Form", None))
|
||||
self.addPort.setText(QCoreApplication.translate("PortEditor", u"Add Port", None))
|
||||
self.removePort.setText(QCoreApplication.translate("PortEditor", u"Remove Port", None))
|
||||
self.nameLabel.setText(QCoreApplication.translate("PortEditor", u"Name:", None))
|
||||
self.typeLabel.setText(QCoreApplication.translate("PortEditor", u"Type:", None))
|
||||
self.typeSignal.setText(QCoreApplication.translate("PortEditor", u"Signal", None))
|
||||
self.typeBond.setText(QCoreApplication.translate("PortEditor", u"Power Bond", None))
|
||||
self.orientationLabel.setText(QCoreApplication.translate("PortEditor", u"Orientation:", None))
|
||||
self.inputOrientation.setText(QCoreApplication.translate("PortEditor", u"Input", None))
|
||||
self.outputOrientation.setText(QCoreApplication.translate("PortEditor", u"Output", None))
|
||||
self.sizeLabel.setText(QCoreApplication.translate("PortEditor", u"Size", None))
|
||||
self.widthSize.setSuffix(QCoreApplication.translate("PortEditor", u" rows", None))
|
||||
self.heightSize.setSuffix(QCoreApplication.translate("PortEditor", u" columns", None))
|
||||
self.domainLabel.setText("")
|
||||
self.multiplicityCheckBox.setText(QCoreApplication.translate("PortEditor", u"Allow multiple connections", None))
|
||||
self.connectionAnnotationLabel.setText(QCoreApplication.translate("PortEditor", u"Connection annotation:", None))
|
||||
self.connectionAnnotationEdit.setPlaceholderText(QCoreApplication.translate("PortEditor", u"For example, + or -", None))
|
||||
self.signalTypeLabel.setText(QCoreApplication.translate("PortEditor", u"Signal Type:", None))
|
||||
self.domainLabel_2.setText(QCoreApplication.translate("PortEditor", u"Domain:", None))
|
||||
self.causalityLabel.setText(QCoreApplication.translate("PortEditor", u"Causality:", None))
|
||||
self.descriptionLabel.setText(QCoreApplication.translate("PortEditor", u"Description:", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
<number>1</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="General">
|
||||
<attribute name="title">
|
||||
@@ -55,7 +55,27 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="labelSnapToGridSize">
|
||||
<property name="text">
|
||||
<string>Snap-to-grid size:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="snapToGridSize">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>256</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
@@ -70,6 +90,34 @@
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="Libraries">
|
||||
<attribute name="title">
|
||||
<string>Libraries</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QListView" name="listView"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="addLibButton">
|
||||
<property name="text">
|
||||
<string>Add library</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="addDirButton">
|
||||
<property name="text">
|
||||
<string>Add directory</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
128
src/bedit_gui/ui/forms/settings_dialog_ui.py
Normal file
128
src/bedit_gui/ui/forms/settings_dialog_ui.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'settings_dialog.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.11.1
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
|
||||
QFont, QFontDatabase, QGradient, QIcon,
|
||||
QImage, QKeySequence, QLinearGradient, QPainter,
|
||||
QPalette, QPixmap, QRadialGradient, QTransform)
|
||||
from PySide6.QtWidgets import (QAbstractButton, QApplication, QComboBox, QDialog,
|
||||
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
|
||||
QListView, QPushButton, QSizePolicy, QSpacerItem,
|
||||
QSpinBox, QTabWidget, QVBoxLayout, QWidget)
|
||||
|
||||
class Ui_Settings(object):
|
||||
def setupUi(self, Settings):
|
||||
if not Settings.objectName():
|
||||
Settings.setObjectName(u"Settings")
|
||||
Settings.resize(400, 230)
|
||||
self.verticalLayout = QVBoxLayout(Settings)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.tabWidget = QTabWidget(Settings)
|
||||
self.tabWidget.setObjectName(u"tabWidget")
|
||||
self.General = QWidget()
|
||||
self.General.setObjectName(u"General")
|
||||
self.formLayout = QFormLayout(self.General)
|
||||
self.formLayout.setObjectName(u"formLayout")
|
||||
self.logLevel = QComboBox(self.General)
|
||||
self.logLevel.addItem("")
|
||||
self.logLevel.addItem("")
|
||||
self.logLevel.addItem("")
|
||||
self.logLevel.addItem("")
|
||||
self.logLevel.setObjectName(u"logLevel")
|
||||
|
||||
self.formLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.logLevel)
|
||||
|
||||
self.lableLogLevel = QLabel(self.General)
|
||||
self.lableLogLevel.setObjectName(u"lableLogLevel")
|
||||
|
||||
self.formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.lableLogLevel)
|
||||
|
||||
self.labelSnapToGridSize = QLabel(self.General)
|
||||
self.labelSnapToGridSize.setObjectName(u"labelSnapToGridSize")
|
||||
|
||||
self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.labelSnapToGridSize)
|
||||
|
||||
self.snapToGridSize = QSpinBox(self.General)
|
||||
self.snapToGridSize.setObjectName(u"snapToGridSize")
|
||||
self.snapToGridSize.setMinimum(1)
|
||||
self.snapToGridSize.setMaximum(256)
|
||||
self.snapToGridSize.setValue(4)
|
||||
|
||||
self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.snapToGridSize)
|
||||
|
||||
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
|
||||
|
||||
self.formLayout.setItem(2, QFormLayout.ItemRole.FieldRole, self.verticalSpacer)
|
||||
|
||||
self.tabWidget.addTab(self.General, "")
|
||||
self.Libraries = QWidget()
|
||||
self.Libraries.setObjectName(u"Libraries")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.Libraries)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.listView = QListView(self.Libraries)
|
||||
self.listView.setObjectName(u"listView")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.listView)
|
||||
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
self.addLibButton = QPushButton(self.Libraries)
|
||||
self.addLibButton.setObjectName(u"addLibButton")
|
||||
|
||||
self.horizontalLayout.addWidget(self.addLibButton)
|
||||
|
||||
self.addDirButton = QPushButton(self.Libraries)
|
||||
self.addDirButton.setObjectName(u"addDirButton")
|
||||
|
||||
self.horizontalLayout.addWidget(self.addDirButton)
|
||||
|
||||
|
||||
self.verticalLayout_2.addLayout(self.horizontalLayout)
|
||||
|
||||
self.tabWidget.addTab(self.Libraries, "")
|
||||
|
||||
self.verticalLayout.addWidget(self.tabWidget)
|
||||
|
||||
self.buttonBox = QDialogButtonBox(Settings)
|
||||
self.buttonBox.setObjectName(u"buttonBox")
|
||||
self.buttonBox.setOrientation(Qt.Orientation.Horizontal)
|
||||
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
|
||||
|
||||
self.verticalLayout.addWidget(self.buttonBox)
|
||||
|
||||
|
||||
self.retranslateUi(Settings)
|
||||
self.buttonBox.accepted.connect(Settings.accept)
|
||||
self.buttonBox.rejected.connect(Settings.reject)
|
||||
|
||||
self.tabWidget.setCurrentIndex(1)
|
||||
|
||||
|
||||
QMetaObject.connectSlotsByName(Settings)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, Settings):
|
||||
Settings.setWindowTitle(QCoreApplication.translate("Settings", u"Settings", None))
|
||||
self.logLevel.setItemText(0, QCoreApplication.translate("Settings", u"Debug", None))
|
||||
self.logLevel.setItemText(1, QCoreApplication.translate("Settings", u"Info", None))
|
||||
self.logLevel.setItemText(2, QCoreApplication.translate("Settings", u"Warning", None))
|
||||
self.logLevel.setItemText(3, QCoreApplication.translate("Settings", u"Error", None))
|
||||
|
||||
self.lableLogLevel.setText(QCoreApplication.translate("Settings", u"Log level:", None))
|
||||
self.labelSnapToGridSize.setText(QCoreApplication.translate("Settings", u"Snap-to-grid size:", None))
|
||||
self.tabWidget.setTabText(self.tabWidget.indexOf(self.General), QCoreApplication.translate("Settings", u"General", None))
|
||||
self.addLibButton.setText(QCoreApplication.translate("Settings", u"Add library", None))
|
||||
self.addDirButton.setText(QCoreApplication.translate("Settings", u"Add directory", None))
|
||||
self.tabWidget.setTabText(self.tabWidget.indexOf(self.Libraries), QCoreApplication.translate("Settings", u"Libraries", None))
|
||||
# retranslateUi
|
||||
|
||||
258
src/bedit_gui/ui/forms/simulation_settings.ui
Normal file
258
src/bedit_gui/ui/forms/simulation_settings.ui
Normal file
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Dialog</class>
|
||||
<widget class="QDialog" name="Dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>562</width>
|
||||
<height>451</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Dialog</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="simulationListLayout">
|
||||
<item>
|
||||
<widget class="QListWidget" name="simulationList"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="simulationButtonLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="addSimulationButton">
|
||||
<property name="text">
|
||||
<string>Add</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="removeSimulationButton">
|
||||
<property name="text">
|
||||
<string>Remove</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="frame">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QFormLayout" name="basicForm">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="nameLabel">
|
||||
<property name="text">
|
||||
<string>Name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="componentLabel">
|
||||
<property name="text">
|
||||
<string>Component:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="nameEdit"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="componentCompoBox"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="timeForm">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="startTimeLabel">
|
||||
<property name="text">
|
||||
<string>Start time:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="simLengthLabel">
|
||||
<property name="text">
|
||||
<string>Simulation length:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QDoubleSpinBox" name="startTimeSpinBox">
|
||||
<property name="suffix">
|
||||
<string> s</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="simLengthSpinBox">
|
||||
<property name="suffix">
|
||||
<string> s</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="stepSizeButton">
|
||||
<property name="text">
|
||||
<string>Step size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="nrOfStepsButton">
|
||||
<property name="text">
|
||||
<string>Number of steps</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="stepSizeLabel">
|
||||
<property name="text">
|
||||
<string>Step size:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="stepLabel">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QDoubleSpinBox" name="stepSizeSpinBox">
|
||||
<property name="suffix">
|
||||
<string> s</string>
|
||||
</property>
|
||||
<property name="decimals">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>0.001000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QSpinBox" name="nrOfStepsSpinBox"/>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="nrOfStepsLabel">
|
||||
<property name="text">
|
||||
<string>Number of steps:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QComboBox" name="simulationMethodComboBox"/>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="simMethodLabel">
|
||||
<property name="text">
|
||||
<string>Simulation method:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="dasslForm">
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="toleranceEdit">
|
||||
<property name="text">
|
||||
<string>1e-06</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="toleranceLabel">
|
||||
<property name="text">
|
||||
<string>Tolerance:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
316
src/bedit_gui/ui/forms/simulation_window.ui
Normal file
316
src/bedit_gui/ui/forms/simulation_window.ui
Normal file
@@ -0,0 +1,316 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>SimulationWindow</class>
|
||||
<widget class="QMainWindow" name="SimulationWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="resultsTabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab">
|
||||
<attribute name="title">
|
||||
<string>Tab 1</string>
|
||||
</attribute>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="progressBar">
|
||||
<property name="value">
|
||||
<number>24</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>19</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>File</string>
|
||||
</property>
|
||||
<addaction name="actionNew_Simulation_Run"/>
|
||||
<addaction name="actionOpen_Simulation_Run"/>
|
||||
<addaction name="actionSave_Simulation_Run"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuSimulation">
|
||||
<property name="title">
|
||||
<string>Simulation</string>
|
||||
</property>
|
||||
<addaction name="actionRestart_Simulation"/>
|
||||
<addaction name="actionRun_Simulation"/>
|
||||
<addaction name="actionStop_Simulation"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSimulation_Options"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuEdit">
|
||||
<property name="title">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSettings"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTools">
|
||||
<property name="title">
|
||||
<string>Tools</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="actionAbout"/>
|
||||
<addaction name="actionAbout_QT"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuEdit"/>
|
||||
<addaction name="menuSimulation"/>
|
||||
<addaction name="menuTools"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QToolBar" name="fileToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionNew_Simulation_Run"/>
|
||||
<addaction name="actionOpen_Simulation_Run"/>
|
||||
<addaction name="actionSave_Simulation_Run"/>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="actionToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="simToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionRestart_Simulation"/>
|
||||
<addaction name="actionRun_Simulation"/>
|
||||
<addaction name="actionStop_Simulation"/>
|
||||
<addaction name="actionSimulation_Options"/>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="logDockWidget">
|
||||
<property name="windowTitle">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>8</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QListView" name="listView"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="simulationTreeDock">
|
||||
<property name="windowTitle">
|
||||
<string>Signals</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>1</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="simulationTree">
|
||||
<attribute name="headerVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string notr="true">1</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="actionRun_Simulation">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/media-playback-start.png</normaloff>:/icons/icons/media-playback-start.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Run Simulation</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionStop_Simulation">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/media-playback-stop.png</normaloff>:/icons/icons/media-playback-stop.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Stop Simulation</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRestart_Simulation">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/media-skip-backward.png</normaloff>:/icons/icons/media-skip-backward.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Restart Simulation</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSimulation_Options">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/configure.png</normaloff>:/icons/icons/configure.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Simulation Options</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNew_Simulation_Run">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-new.png</normaloff>:/icons/icons/document-new.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>New Simulation Run</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpen_Simulation_Run">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Open Simulation Run</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_Simulation_Run">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save Simulation Run</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionUndo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Undo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Z</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRedo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Redo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Y</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSettings">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/preferences-system.png</normaloff>:/icons/icons/preferences-system.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Settings</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="text">
|
||||
<string>About</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout_QT">
|
||||
<property name="text">
|
||||
<string>About QT</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../resources/resources.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
138
src/bedit_gui/utils/icon.py
Normal file
138
src/bedit_gui/utils/icon.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from PySide6.QtCore import QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap, QRegion
|
||||
|
||||
from bedit_core.models import Port, PortID, SignalDirection
|
||||
from bedit_gui.models import Ellipse, Icon, Line, LineType, Rectangle, Text
|
||||
|
||||
PORT_SIZE = 16
|
||||
DEFAULT_ICON_SIZE = QSize(48, 48)
|
||||
EMPTY_NATURAL_ICON_SIZE = QSize(32, 32)
|
||||
ICON_MARGIN = 4
|
||||
ICON_PREVIEW_OVERSAMPLE = 4
|
||||
|
||||
|
||||
def get_bounding_box(icon: Icon) -> QRectF:
|
||||
points: list[tuple[float, float]] = []
|
||||
|
||||
for shape in icon.shapes.values():
|
||||
x, y = shape.pos
|
||||
points.append((x, y))
|
||||
if isinstance(shape, (Rectangle, Ellipse, Text)):
|
||||
points.append((x + shape.width, y + shape.height))
|
||||
elif isinstance(shape, Line):
|
||||
points.append(shape.end)
|
||||
|
||||
for x, y in icon.port_positions.values():
|
||||
points.append((x, y))
|
||||
points.append((x + PORT_SIZE, y + PORT_SIZE))
|
||||
|
||||
if not points:
|
||||
return QRectF()
|
||||
|
||||
left = min(point[0] for point in points)
|
||||
top = min(point[1] for point in points)
|
||||
right = max(point[0] for point in points)
|
||||
bottom = max(point[1] for point in points)
|
||||
return QRectF(left, top, right - left, bottom - top)
|
||||
|
||||
|
||||
def get_pixmap_bounding_box(pixmap: QPixmap) -> QRectF:
|
||||
"""Return the bounds of the pixels actually painted in a transparent pixmap."""
|
||||
bounds = QRegion(pixmap.mask()).boundingRect()
|
||||
return QRectF(bounds) if not bounds.isEmpty() else QRectF(0, 0, pixmap.width(), pixmap.height())
|
||||
|
||||
|
||||
def get_natural_icon_size(icon: Icon) -> QSize:
|
||||
bounds = get_bounding_box(icon)
|
||||
if bounds.isEmpty():
|
||||
return QSize(EMPTY_NATURAL_ICON_SIZE)
|
||||
return QSize(max(1, ceil(bounds.width()) + ICON_MARGIN), max(1, ceil(bounds.height()) + ICON_MARGIN))
|
||||
|
||||
|
||||
def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
|
||||
pixmap = QPixmap(size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
bounds = get_bounding_box(icon)
|
||||
if not icon.shapes and not icon.port_positions:
|
||||
return QIcon(pixmap)
|
||||
|
||||
scale = _render_scale(bounds, size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.translate(size.width() / 2, size.height() / 2)
|
||||
painter.scale(scale, scale)
|
||||
painter.translate(-bounds.center())
|
||||
|
||||
for shape in sorted(icon.shapes.values(), key=lambda item: item.layer):
|
||||
if isinstance(shape, Rectangle):
|
||||
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||
painter.setBrush(QBrush(_color(shape.fill_color)))
|
||||
painter.drawRoundedRect(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), shape.corner_radius, shape.corner_radius)
|
||||
elif isinstance(shape, Ellipse):
|
||||
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||
painter.setBrush(QBrush(_color(shape.fill_color)))
|
||||
painter.drawEllipse(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height))
|
||||
elif isinstance(shape, Text):
|
||||
font = QFont()
|
||||
font.setPixelSize(max(1, round(shape.size)))
|
||||
font.setBold(shape.bold)
|
||||
font.setItalic(shape.italic)
|
||||
painter.setFont(font)
|
||||
painter.setPen(_color(shape.color))
|
||||
painter.drawText(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, shape.text)
|
||||
elif isinstance(shape, Line):
|
||||
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||
painter.drawLine(shape.pos[0], shape.pos[1], shape.end[0], shape.end[1])
|
||||
|
||||
if render_ports:
|
||||
painter.setPen(QPen(QColor("#000000")))
|
||||
for port_id, position in icon.port_positions.items():
|
||||
port = ports.get(port_id)
|
||||
if port is None:
|
||||
continue
|
||||
color = QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")
|
||||
painter.setBrush(QBrush(color))
|
||||
painter.drawRect(position[0], position[1], PORT_SIZE, PORT_SIZE)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def render_fitted_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE) -> QIcon:
|
||||
"""Render an icon preview with its painted content fitted to a uniform size."""
|
||||
render_size = QSize(size.width() * ICON_PREVIEW_OVERSAMPLE, size.height() * ICON_PREVIEW_OVERSAMPLE)
|
||||
rendered = render_icon(icon, ports, render_size).pixmap(render_size)
|
||||
bounds = get_pixmap_bounding_box(rendered).toAlignedRect()
|
||||
content = rendered.copy(bounds)
|
||||
available = QSize(max(1, size.width() - ICON_MARGIN), max(1, size.height() - ICON_MARGIN))
|
||||
fitted = content.scaled(available, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
pixmap = QPixmap(size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.drawPixmap((size.width() - fitted.width()) // 2, (size.height() - fitted.height()) // 2, fitted)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def _render_scale(bounds: QRectF, size: QSize) -> float:
|
||||
available_width = max(1, size.width() - ICON_MARGIN)
|
||||
available_height = max(1, size.height() - ICON_MARGIN)
|
||||
return min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
|
||||
|
||||
|
||||
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
|
||||
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
|
||||
if line_type is LineType.NONE:
|
||||
return QPen(Qt.PenStyle.NoPen)
|
||||
return QPen(_color(color), thickness, styles[line_type])
|
||||
|
||||
|
||||
def _color(value: str) -> QColor:
|
||||
color = value.removeprefix("#")
|
||||
if len(color) == 8:
|
||||
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
|
||||
return QColor(value)
|
||||
4
src/bedit_gui/versions.py
Normal file
4
src/bedit_gui/versions.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Independent versions for the BEdit desktop applications."""
|
||||
|
||||
BEDIT_VERSION = "0.2.0"
|
||||
BESIM_VERSION = "0.1.0"
|
||||
42
src/bedit_gui/views/color_button.py
Normal file
42
src/bedit_gui/views/color_button.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QColorDialog, QPushButton, QWidget
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
def __init__(self, color: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("colorButton")
|
||||
self._color = self._from_rgba(color)
|
||||
self.clicked.connect(self._select_color)
|
||||
self._update_display()
|
||||
|
||||
def color(self) -> str:
|
||||
return f"#{self._color.red():02x}{self._color.green():02x}{self._color.blue():02x}{self._color.alpha():02x}"
|
||||
|
||||
def set_color(self, color: str) -> None:
|
||||
self._color = self._from_rgba(color)
|
||||
self._update_display()
|
||||
|
||||
def _select_color(self) -> None:
|
||||
parent = self.window()
|
||||
dialog = QColorDialog(self._color, parent if parent is not self else None)
|
||||
dialog.setOption(QColorDialog.ColorDialogOption.ShowAlphaChannel)
|
||||
if dialog.exec() == QColorDialog.DialogCode.Accepted:
|
||||
self._color = dialog.selectedColor()
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
self.setText(self.color())
|
||||
foreground = "#000000" if self._color.lightness() > 127 or self._color.alpha() < 128 else "#ffffff"
|
||||
self.setStyleSheet(f"QPushButton#colorButton {{ background-color: rgba({self._color.red()}, {self._color.green()}, {self._color.blue()}, {self._color.alpha()}); color: {foreground}; }}")
|
||||
|
||||
@staticmethod
|
||||
def _from_rgba(value: str) -> QColor:
|
||||
color = value.removeprefix("#")
|
||||
if len(color) == 6:
|
||||
color += "ff"
|
||||
if len(color) == 8:
|
||||
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
|
||||
return QColor(value)
|
||||
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from bedit_core.models import Port, PortID
|
||||
from bedit_gui.models import PortMetadata
|
||||
from bedit_gui.views.port_editor_widget import PortEditorWidget
|
||||
|
||||
|
||||
@@ -17,6 +18,7 @@ class InterfaceEditorDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
ports: dict[PortID, Port],
|
||||
port_metadata: dict[PortID, PortMetadata] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -25,7 +27,7 @@ class InterfaceEditorDialog(QDialog):
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.editor = PortEditorWidget(self)
|
||||
self.editor.set_ports(ports)
|
||||
self.editor.set_ports(ports, port_metadata)
|
||||
layout.addWidget(self.editor)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
@@ -38,3 +40,6 @@ class InterfaceEditorDialog(QDialog):
|
||||
|
||||
def ports(self) -> dict[PortID, Port]:
|
||||
return self.editor.ports()
|
||||
|
||||
def port_metadata(self) -> dict[PortID, PortMetadata]:
|
||||
return self.editor.port_metadata()
|
||||
|
||||
139
src/bedit_gui/views/dialogs/plot_settings_dialog.py
Normal file
139
src/bedit_gui/views/dialogs/plot_settings_dialog.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QColorDialog, QDialog, QMessageBox, QWidget
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings, SimulationTraceSettings
|
||||
from bedit_gui.ui.generated.ui_plot_settings import Ui_PlotSettingsDialog
|
||||
|
||||
|
||||
class PlotSettingsDialog(QDialog):
|
||||
def __init__(self, settings: SimulationPlotSettings, signals: list[str], parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ui = Ui_PlotSettingsDialog()
|
||||
self.ui.setupUi(self)
|
||||
self._trace_settings = deepcopy(settings.traces)
|
||||
self._current_trace: str | None = None
|
||||
self._trace_color = ""
|
||||
self._configure_controls()
|
||||
self._load(settings)
|
||||
self.ui.traceCombo.addItems(signals)
|
||||
self.ui.traceCombo.currentIndexChanged.connect(self._trace_changed)
|
||||
self.ui.traceColorButton.clicked.connect(self._choose_trace_color)
|
||||
self.ui.traceColorResetButton.clicked.connect(self._reset_trace_color)
|
||||
self.ui.xAutoCheck.toggled.connect(self._update_limit_controls)
|
||||
self.ui.yAutoCheck.toggled.connect(self._update_limit_controls)
|
||||
self._update_limit_controls()
|
||||
self._trace_changed(self.ui.traceCombo.currentIndex())
|
||||
|
||||
def settings(self) -> SimulationPlotSettings:
|
||||
self._store_trace()
|
||||
return SimulationPlotSettings(
|
||||
title=self.ui.titleEdit.text().strip(), x_label=self.ui.xLabelEdit.text().strip(), y_label=self.ui.yLabelEdit.text().strip(),
|
||||
x_scale=self.ui.xScaleCombo.currentData(), y_scale=self.ui.yScaleCombo.currentData(),
|
||||
x_auto=self.ui.xAutoCheck.isChecked(), y_auto=self.ui.yAutoCheck.isChecked(),
|
||||
x_min=self.ui.xMinSpin.value(), x_max=self.ui.xMaxSpin.value(), y_min=self.ui.yMinSpin.value(), y_max=self.ui.yMaxSpin.value(),
|
||||
grid_visible=self.ui.gridGroup.isChecked(), grid_axis=self.ui.gridAxisCombo.currentData(), grid_style=self.ui.gridStyleCombo.currentData(), grid_alpha=self.ui.gridOpacitySlider.value() / 100.0,
|
||||
legend_visible=self.ui.legendGroup.isChecked(), legend_location=self.ui.legendLocationCombo.currentData(),
|
||||
traces=deepcopy(self._trace_settings),
|
||||
)
|
||||
|
||||
def accept(self) -> None:
|
||||
settings = self.settings()
|
||||
if not settings.x_auto and settings.x_min >= settings.x_max:
|
||||
QMessageBox.warning(self, "Invalid x-axis limits", "The x-axis maximum must be greater than its minimum.")
|
||||
return
|
||||
if not settings.y_auto and settings.y_min >= settings.y_max:
|
||||
QMessageBox.warning(self, "Invalid y-axis limits", "The y-axis maximum must be greater than its minimum.")
|
||||
return
|
||||
if settings.x_scale == "log" and not settings.x_auto and settings.x_min <= 0:
|
||||
QMessageBox.warning(self, "Invalid x-axis limits", "A logarithmic x axis requires a positive minimum.")
|
||||
return
|
||||
if settings.y_scale == "log" and not settings.y_auto and settings.y_min <= 0:
|
||||
QMessageBox.warning(self, "Invalid y-axis limits", "A logarithmic y axis requires a positive minimum.")
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def _configure_controls(self) -> None:
|
||||
for combo in (self.ui.xScaleCombo, self.ui.yScaleCombo):
|
||||
combo.setItemData(0, "linear")
|
||||
combo.setItemData(1, "log")
|
||||
for label, value in (("Both axes", "both"), ("X axis only", "x"), ("Y axis only", "y")):
|
||||
self.ui.gridAxisCombo.setItemData(self.ui.gridAxisCombo.findText(label), value)
|
||||
for index, value in enumerate(("-", "--", ":", "-.")):
|
||||
self.ui.gridStyleCombo.setItemData(index, value)
|
||||
self.ui.traceLineStyleCombo.setItemData(index, value)
|
||||
self.ui.traceLineStyleCombo.setItemData(4, "None")
|
||||
for label, value in (("None", ""), ("Point", "."), ("Circle", "o"), ("Square", "s"), ("Triangle up", "^"), ("Triangle down", "v"), ("Diamond", "D"), ("Plus", "+"), ("Cross", "x"), ("Star", "*")):
|
||||
self.ui.traceMarkerCombo.addItem(label, value)
|
||||
for label, value in (("Automatic", "best"), ("Upper right", "upper right"), ("Upper left", "upper left"), ("Lower right", "lower right"), ("Lower left", "lower left"), ("Center right", "center right"), ("Center left", "center left"), ("Upper center", "upper center"), ("Lower center", "lower center"), ("Center", "center")):
|
||||
self.ui.legendLocationCombo.addItem(label, value)
|
||||
for spin in (self.ui.xMinSpin, self.ui.xMaxSpin, self.ui.yMinSpin, self.ui.yMaxSpin):
|
||||
spin.setRange(-1e100, 1e100)
|
||||
|
||||
def _load(self, settings: SimulationPlotSettings) -> None:
|
||||
self.ui.titleEdit.setText(settings.title)
|
||||
self.ui.xLabelEdit.setText(settings.x_label)
|
||||
self.ui.yLabelEdit.setText(settings.y_label)
|
||||
self.ui.xScaleCombo.setCurrentIndex(self.ui.xScaleCombo.findData(settings.x_scale))
|
||||
self.ui.yScaleCombo.setCurrentIndex(self.ui.yScaleCombo.findData(settings.y_scale))
|
||||
self.ui.xAutoCheck.setChecked(settings.x_auto)
|
||||
self.ui.yAutoCheck.setChecked(settings.y_auto)
|
||||
self.ui.xMinSpin.setValue(settings.x_min)
|
||||
self.ui.xMaxSpin.setValue(settings.x_max)
|
||||
self.ui.yMinSpin.setValue(settings.y_min)
|
||||
self.ui.yMaxSpin.setValue(settings.y_max)
|
||||
self.ui.gridGroup.setChecked(settings.grid_visible)
|
||||
self.ui.gridAxisCombo.setCurrentIndex(self.ui.gridAxisCombo.findData(settings.grid_axis))
|
||||
self.ui.gridStyleCombo.setCurrentIndex(self.ui.gridStyleCombo.findData(settings.grid_style))
|
||||
self.ui.gridOpacitySlider.setValue(round(settings.grid_alpha * 100))
|
||||
self.ui.legendGroup.setChecked(settings.legend_visible)
|
||||
self.ui.legendLocationCombo.setCurrentIndex(self.ui.legendLocationCombo.findData(settings.legend_location))
|
||||
|
||||
def _update_limit_controls(self) -> None:
|
||||
for widget in (self.ui.xMinLabel, self.ui.xMinSpin, self.ui.xMaxLabel, self.ui.xMaxSpin):
|
||||
widget.setEnabled(not self.ui.xAutoCheck.isChecked())
|
||||
for widget in (self.ui.yMinLabel, self.ui.yMinSpin, self.ui.yMaxLabel, self.ui.yMaxSpin):
|
||||
widget.setEnabled(not self.ui.yAutoCheck.isChecked())
|
||||
|
||||
def _trace_changed(self, index: int) -> None:
|
||||
self._store_trace()
|
||||
self._current_trace = self.ui.traceCombo.itemText(index) if index >= 0 else None
|
||||
self.ui.traceGroup.setEnabled(self._current_trace is not None)
|
||||
trace = self._trace_settings.get(self._current_trace, SimulationTraceSettings())
|
||||
self.ui.traceVisibleCheck.setChecked(trace.visible)
|
||||
self.ui.traceLegendEdit.setText(trace.label)
|
||||
self.ui.traceLineStyleCombo.setCurrentIndex(self.ui.traceLineStyleCombo.findData(trace.line_style))
|
||||
self.ui.traceLineWidthSpin.setValue(trace.line_width)
|
||||
self.ui.traceMarkerCombo.setCurrentIndex(self.ui.traceMarkerCombo.findData(trace.marker))
|
||||
self.ui.traceMarkerSizeSpin.setValue(trace.marker_size)
|
||||
self._set_trace_color(trace.color)
|
||||
|
||||
def _store_trace(self) -> None:
|
||||
if self._current_trace is None:
|
||||
return
|
||||
trace = SimulationTraceSettings(
|
||||
visible=self.ui.traceVisibleCheck.isChecked(), label=self.ui.traceLegendEdit.text().strip(), color=self._trace_color,
|
||||
line_style=self.ui.traceLineStyleCombo.currentData(), line_width=self.ui.traceLineWidthSpin.value(),
|
||||
marker=self.ui.traceMarkerCombo.currentData(), marker_size=self.ui.traceMarkerSizeSpin.value(),
|
||||
)
|
||||
if trace == SimulationTraceSettings():
|
||||
self._trace_settings.pop(self._current_trace, None)
|
||||
else:
|
||||
self._trace_settings[self._current_trace] = trace
|
||||
|
||||
def _choose_trace_color(self) -> None:
|
||||
initial = QColor(self._trace_color) if self._trace_color else QColor("black")
|
||||
color = QColorDialog.getColor(initial, self, "Select trace color")
|
||||
if color.isValid():
|
||||
self._set_trace_color(color.name(QColor.NameFormat.HexRgb))
|
||||
|
||||
def _reset_trace_color(self) -> None:
|
||||
self._set_trace_color("")
|
||||
|
||||
def _set_trace_color(self, color: str) -> None:
|
||||
self._trace_color = color
|
||||
self.ui.traceColorButton.setText(color or "Automatic")
|
||||
self.ui.traceColorButton.setStyleSheet(f"background-color: {color};" if color else "")
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QWidget
|
||||
from PySide6.QtCore import QStringListModel, Qt
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import QAbstractItemView, QDialog, QFileDialog, QWidget
|
||||
|
||||
from bedit_gui.ui.generated.ui_settings_dialog import Ui_Settings
|
||||
|
||||
@@ -20,6 +23,8 @@ class SettingsDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
log_level: int,
|
||||
snap_to_grid_size: int,
|
||||
library_paths: list[str],
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -35,7 +40,46 @@ class SettingsDialog(QDialog):
|
||||
self.ui.logLevel.setCurrentIndex(
|
||||
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO)
|
||||
)
|
||||
self.ui.snapToGridSize.setValue(snap_to_grid_size)
|
||||
self._library_paths = QStringListModel(list(library_paths), self)
|
||||
self.ui.listView.setModel(self._library_paths)
|
||||
self.ui.listView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.ui.addLibButton.clicked.connect(self._add_library_file)
|
||||
self.ui.addDirButton.clicked.connect(self._add_library_directory)
|
||||
self._delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self.ui.listView)
|
||||
self._delete_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
self._delete_shortcut.activated.connect(self._delete_selected_paths)
|
||||
|
||||
@property
|
||||
def log_level(self) -> int:
|
||||
return int(self.ui.logLevel.currentData())
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int:
|
||||
return self.ui.snapToGridSize.value()
|
||||
|
||||
@property
|
||||
def library_paths(self) -> list[str]:
|
||||
return self._library_paths.stringList()
|
||||
|
||||
def _add_library_file(self) -> None:
|
||||
path, _selected_filter = QFileDialog.getOpenFileName(self, "Add BEdit Library", "", "BEdit documents (*.bedit.json *.beb *.json)")
|
||||
if path:
|
||||
self._add_library_path(path)
|
||||
|
||||
def _add_library_directory(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(self, "Add Library Directory")
|
||||
if path:
|
||||
self._add_library_path(path)
|
||||
|
||||
def _add_library_path(self, path: str) -> None:
|
||||
normalized = str(Path(path).resolve())
|
||||
paths = self._library_paths.stringList()
|
||||
if normalized not in paths:
|
||||
paths.append(normalized)
|
||||
self._library_paths.setStringList(paths)
|
||||
|
||||
def _delete_selected_paths(self) -> None:
|
||||
rows = sorted((index.row() for index in self.ui.listView.selectionModel().selectedRows()), reverse=True)
|
||||
for row in rows:
|
||||
self._library_paths.removeRow(row)
|
||||
|
||||
215
src/bedit_gui/views/dialogs/simulation_settings_dialog.py
Normal file
215
src/bedit_gui/views/dialogs/simulation_settings_dialog.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDoubleValidator
|
||||
from PySide6.QtWidgets import QDialog, QLayout, QListWidgetItem, QMessageBox, QWidget
|
||||
|
||||
from bedit_core.models import ComponentID
|
||||
from bedit_gui.models import Simulation, SimulationDatabase, SimulationID, SimulationMethod
|
||||
from bedit_gui.ui.generated.ui_simulation_settings import Ui_Dialog
|
||||
|
||||
|
||||
class SimulationSettingsDialog(QDialog):
|
||||
"""Editor for the detached simulation database of a document."""
|
||||
|
||||
def __init__(self, database: SimulationDatabase, components: list[tuple[ComponentID, str]], parent: QWidget | None = None, *, show_simulation_list: bool = True) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.ui = Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
self.setWindowTitle("Simulation Settings")
|
||||
self._database = deepcopy(database)
|
||||
self._simulation_ids = list(self._database.simulations)
|
||||
self._components = components
|
||||
self._loading = False
|
||||
|
||||
if not show_simulation_list:
|
||||
self._set_layout_visible(self.ui.simulationListLayout, False)
|
||||
|
||||
self.ui.startTimeSpinBox.setRange(-1e12, 1e12)
|
||||
self.ui.simLengthSpinBox.setRange(0.0, 1e12)
|
||||
self.ui.stepSizeSpinBox.setRange(1e-9, 1e12)
|
||||
self.ui.nrOfStepsSpinBox.setRange(1, 1_000_000_000)
|
||||
tolerance_validator = QDoubleValidator(0.0, 1e12, 16, self)
|
||||
tolerance_validator.setNotation(QDoubleValidator.Notation.ScientificNotation)
|
||||
self.ui.toleranceEdit.setValidator(tolerance_validator)
|
||||
for component_id, path in components:
|
||||
self.ui.componentCompoBox.addItem(path, str(component_id))
|
||||
for method in SimulationMethod:
|
||||
self.ui.simulationMethodComboBox.addItem(method.value.upper(), method.value)
|
||||
|
||||
self.ui.simulationList.currentRowChanged.connect(self._selection_changed)
|
||||
self.ui.addSimulationButton.clicked.connect(self._add_simulation)
|
||||
self.ui.removeSimulationButton.clicked.connect(self._remove_simulation)
|
||||
self.ui.nameEdit.textEdited.connect(self._form_changed)
|
||||
self.ui.componentCompoBox.currentIndexChanged.connect(self._form_changed)
|
||||
self.ui.startTimeSpinBox.valueChanged.connect(self._form_changed)
|
||||
self.ui.simLengthSpinBox.valueChanged.connect(self._form_changed)
|
||||
self.ui.stepSizeButton.toggled.connect(self._form_changed)
|
||||
self.ui.nrOfStepsButton.toggled.connect(self._form_changed)
|
||||
self.ui.stepSizeSpinBox.valueChanged.connect(self._form_changed)
|
||||
self.ui.nrOfStepsSpinBox.valueChanged.connect(self._form_changed)
|
||||
self.ui.simulationMethodComboBox.currentIndexChanged.connect(self._form_changed)
|
||||
self.ui.toleranceEdit.textEdited.connect(self._form_changed)
|
||||
|
||||
self._rebuild_list(self._database.active_simulation)
|
||||
|
||||
def database(self) -> SimulationDatabase:
|
||||
return deepcopy(self._database)
|
||||
|
||||
def accept(self) -> None:
|
||||
for simulation in self._database.simulations.values():
|
||||
if not simulation.name.strip():
|
||||
QMessageBox.warning(self, "Invalid simulation", "Every simulation must have a name.")
|
||||
return
|
||||
if simulation.component not in {component_id for component_id, _ in self._components}:
|
||||
QMessageBox.warning(self, "Invalid simulation", f"Select an existing component for {simulation.name}.")
|
||||
return
|
||||
if simulation.dassl_tolerance <= 0:
|
||||
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
|
||||
return
|
||||
if simulation.duration <= 0:
|
||||
QMessageBox.warning(self, "Invalid simulation", "The simulation length must be greater than zero.")
|
||||
return
|
||||
simulation.name = simulation.name.strip()
|
||||
self._database.active_simulation = self._simulation_id()
|
||||
super().accept()
|
||||
|
||||
def _rebuild_list(self, selected_id: SimulationID | None = None) -> None:
|
||||
self.ui.simulationList.clear()
|
||||
for simulation_id in self._simulation_ids:
|
||||
item = QListWidgetItem(self._database.simulations[simulation_id].name)
|
||||
item.setData(Qt.ItemDataRole.UserRole, simulation_id)
|
||||
self.ui.simulationList.addItem(item)
|
||||
if self._simulation_ids:
|
||||
if selected_id not in self._database.simulations:
|
||||
selected_id = self._simulation_ids[0]
|
||||
self.ui.simulationList.setCurrentRow(self._simulation_ids.index(selected_id))
|
||||
else:
|
||||
self._set_editor_enabled(False)
|
||||
|
||||
def _selection_changed(self, row: int) -> None:
|
||||
simulation_id = self._simulation_id(row)
|
||||
self._set_editor_enabled(simulation_id is not None)
|
||||
self.ui.removeSimulationButton.setEnabled(simulation_id is not None)
|
||||
if simulation_id is not None:
|
||||
self._load_simulation(self._database.simulations[simulation_id])
|
||||
|
||||
def _simulation_id(self, row: int | None = None) -> SimulationID | None:
|
||||
if row is None:
|
||||
row = self.ui.simulationList.currentRow()
|
||||
if row < 0 or row >= len(self._simulation_ids):
|
||||
return None
|
||||
return self._simulation_ids[row]
|
||||
|
||||
def _load_simulation(self, simulation: Simulation) -> None:
|
||||
self._loading = True
|
||||
self.ui.nameEdit.setText(simulation.name)
|
||||
component_index = self.ui.componentCompoBox.findData(str(simulation.component))
|
||||
if component_index < 0:
|
||||
self.ui.componentCompoBox.addItem(f"Missing component ({simulation.component})", str(simulation.component))
|
||||
component_index = self.ui.componentCompoBox.count() - 1
|
||||
self.ui.componentCompoBox.setCurrentIndex(component_index)
|
||||
self.ui.startTimeSpinBox.setValue(simulation.start_time)
|
||||
self.ui.simLengthSpinBox.setValue(simulation.duration)
|
||||
self.ui.stepSizeButton.setChecked(simulation.use_timed_steps)
|
||||
self.ui.nrOfStepsButton.setChecked(not simulation.use_timed_steps)
|
||||
self.ui.stepSizeSpinBox.setValue(simulation.step_size)
|
||||
self.ui.nrOfStepsSpinBox.setValue(simulation.number_of_steps)
|
||||
self.ui.simulationMethodComboBox.setCurrentIndex(self.ui.simulationMethodComboBox.findData(simulation.method.value))
|
||||
self.ui.toleranceEdit.setText(str(simulation.dassl_tolerance))
|
||||
self._loading = False
|
||||
self._update_step_inputs()
|
||||
|
||||
def _form_changed(self, *_args: object) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
simulation_id = self._simulation_id()
|
||||
if simulation_id is None:
|
||||
return
|
||||
|
||||
self._update_step_inputs()
|
||||
simulation = self._database.simulations[simulation_id]
|
||||
try:
|
||||
tolerance = float(self.ui.toleranceEdit.text())
|
||||
except ValueError:
|
||||
tolerance = simulation.dassl_tolerance
|
||||
component_data = self.ui.componentCompoBox.currentData()
|
||||
component = ComponentID(component_data) if isinstance(component_data, str) and component_data else simulation.component
|
||||
method_data = self.ui.simulationMethodComboBox.currentData()
|
||||
method = SimulationMethod(method_data) if isinstance(method_data, str) else simulation.method
|
||||
self._database.simulations[simulation_id] = Simulation(
|
||||
component=component,
|
||||
name=self.ui.nameEdit.text(),
|
||||
start_time=self.ui.startTimeSpinBox.value(),
|
||||
duration=self.ui.simLengthSpinBox.value(),
|
||||
use_timed_steps=self.ui.stepSizeButton.isChecked(),
|
||||
number_of_steps=self.ui.nrOfStepsSpinBox.value(),
|
||||
step_size=self.ui.stepSizeSpinBox.value(),
|
||||
method=method,
|
||||
dassl_tolerance=tolerance,
|
||||
)
|
||||
self.ui.simulationList.item(self.ui.simulationList.currentRow()).setText(self.ui.nameEdit.text())
|
||||
|
||||
def _add_simulation(self) -> None:
|
||||
if not self._components:
|
||||
return
|
||||
simulation_id = SimulationID()
|
||||
simulation = Simulation(
|
||||
component=self._components[0][0],
|
||||
name=self._unique_name("Simulation"),
|
||||
start_time=0.0,
|
||||
duration=1.0,
|
||||
use_timed_steps=False,
|
||||
number_of_steps=500,
|
||||
step_size=0.001,
|
||||
method=SimulationMethod.DASSL,
|
||||
dassl_tolerance=1e-6,
|
||||
)
|
||||
self._database.simulations[simulation_id] = simulation
|
||||
self._simulation_ids.append(simulation_id)
|
||||
self._rebuild_list(simulation_id)
|
||||
|
||||
def _remove_simulation(self) -> None:
|
||||
simulation_id = self._simulation_id()
|
||||
if simulation_id is None:
|
||||
return
|
||||
row = self._simulation_ids.index(simulation_id)
|
||||
del self._database.simulations[simulation_id]
|
||||
self._simulation_ids.remove(simulation_id)
|
||||
if self._database.active_simulation == simulation_id:
|
||||
self._database.active_simulation = None
|
||||
selected = self._simulation_ids[min(row, len(self._simulation_ids) - 1)] if self._simulation_ids else None
|
||||
self._rebuild_list(selected)
|
||||
|
||||
def _unique_name(self, base: str) -> str:
|
||||
names = {simulation.name for simulation in self._database.simulations.values()}
|
||||
if base not in names:
|
||||
return base
|
||||
index = 2
|
||||
while f"{base} {index}" in names:
|
||||
index += 1
|
||||
return f"{base} {index}"
|
||||
|
||||
def _update_step_inputs(self) -> None:
|
||||
timed = self.ui.stepSizeButton.isChecked()
|
||||
self.ui.stepSizeSpinBox.setEnabled(timed)
|
||||
self.ui.nrOfStepsSpinBox.setEnabled(not timed)
|
||||
|
||||
def _set_editor_enabled(self, enabled: bool) -> None:
|
||||
self.ui.frame.setEnabled(enabled)
|
||||
self.ui.removeSimulationButton.setEnabled(enabled)
|
||||
self.ui.addSimulationButton.setEnabled(bool(self._components))
|
||||
|
||||
@classmethod
|
||||
def _set_layout_visible(cls, layout: QLayout, visible: bool) -> None:
|
||||
for index in range(layout.count()):
|
||||
item = layout.itemAt(index)
|
||||
widget = item.widget()
|
||||
child_layout = item.layout()
|
||||
if widget is not None:
|
||||
widget.setVisible(visible)
|
||||
elif child_layout is not None:
|
||||
cls._set_layout_visible(child_layout, visible)
|
||||
178
src/bedit_gui/views/equation_editor_widget.py
Normal file
178
src/bedit_gui/views/equation_editor_widget.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QTimer, Qt, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from bedit_core.models import Component, EquationImplementation, PortID
|
||||
from bedit_gui.models import PortMetadata
|
||||
from bedit_gui.ui.generated.ui_equation_editor_widget import Ui_equationEditorWidget
|
||||
from bedit_gui.views.param_editor_widget import ParamEditorWidget
|
||||
from bedit_gui.views.port_editor_widget import PortEditorWidget
|
||||
|
||||
|
||||
class EquationEditorWidget(QWidget):
|
||||
"""Editor that keeps an equation component synchronized with its fields."""
|
||||
|
||||
component_changed = Signal(object)
|
||||
equation_text_change_requested = Signal(object, str, object, int)
|
||||
port_metadata_change_requested = Signal(object, object)
|
||||
sidebar_visible_changed = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.ui = Ui_equationEditorWidget()
|
||||
self.ui.setupUi(self)
|
||||
self._component: Component | None = None
|
||||
self._port_metadata: dict[PortID, PortMetadata] = {}
|
||||
self._loading = False
|
||||
self._edit_id = 0
|
||||
self._edit_timer = QTimer(self)
|
||||
self._edit_timer.setInterval(750)
|
||||
self._edit_timer.setSingleShot(True)
|
||||
self._edit_timer.timeout.connect(self.finish_text_edit)
|
||||
|
||||
self.param_editor = ParamEditorWidget(self)
|
||||
self.port_editor = PortEditorWidget(self)
|
||||
self._replace_placeholder(self.ui.paramEditor, self.param_editor)
|
||||
self._replace_placeholder(self.ui.portEditor, self.port_editor)
|
||||
self.ui.sidebarButton.clicked.connect(self.toggle_sidebar)
|
||||
self.set_sidebar_visible(True)
|
||||
|
||||
for editor in self._text_editors():
|
||||
editor.setUndoRedoEnabled(False)
|
||||
editor.installEventFilter(self)
|
||||
editor.textChanged.connect(self._text_changed)
|
||||
self.param_editor.params_changed.connect(self._params_changed)
|
||||
self.port_editor.ports_changed.connect(self._ports_changed)
|
||||
|
||||
self._set_editors_enabled(False)
|
||||
|
||||
def set_component(self, component: Component | None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
if component is not None and not isinstance(component.implementation, EquationImplementation):
|
||||
raise TypeError("EquationEditorWidget only supports components with an equation implementation")
|
||||
|
||||
self._component = component
|
||||
self._port_metadata = port_metadata or {}
|
||||
self.refresh()
|
||||
|
||||
def component(self) -> Component | None:
|
||||
return self._component
|
||||
|
||||
def finish_text_edit(self) -> None:
|
||||
self._edit_timer.stop()
|
||||
self._edit_id += 1
|
||||
|
||||
def toggle_sidebar(self) -> None:
|
||||
self.set_sidebar_visible(not self.param_editor.isVisibleTo(self))
|
||||
|
||||
def set_sidebar_visible(self, visible: bool) -> None:
|
||||
changed = self.param_editor.isVisibleTo(self) != visible
|
||||
self.param_editor.setVisible(visible)
|
||||
self.port_editor.setVisible(visible)
|
||||
self.ui.sidebarButton.setArrowType(Qt.ArrowType.RightArrow if visible else Qt.ArrowType.LeftArrow)
|
||||
self.ui.sidebarButton.setToolTip("Hide parameter and port editors" if visible else "Show parameter and port editors")
|
||||
if changed:
|
||||
self.sidebar_visible_changed.emit(visible)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Reload the editors after the component was changed externally."""
|
||||
self._loading = True
|
||||
component = self._component
|
||||
if component is None:
|
||||
self.ui.declarationsTextEdit.clear()
|
||||
self.ui.initialEquationsTextEdit.clear()
|
||||
self.ui.equationsTextEdit.clear()
|
||||
self.param_editor.set_params({})
|
||||
self.port_editor.set_ports({})
|
||||
else:
|
||||
implementation = component.implementation
|
||||
assert isinstance(implementation, EquationImplementation)
|
||||
self.ui.declarationsTextEdit.setPlainText("\n".join(implementation.declarations))
|
||||
self.ui.initialEquationsTextEdit.setPlainText("\n".join(implementation.initial_equations))
|
||||
self.ui.equationsTextEdit.setPlainText("\n".join(implementation.equations))
|
||||
self.param_editor.set_params(component.parameters)
|
||||
self.port_editor.set_ports(component.interface.ports, self._port_metadata)
|
||||
self._loading = False
|
||||
self._set_editors_enabled(component is not None)
|
||||
|
||||
def refresh_text(self, section: str) -> None:
|
||||
if self._component is None:
|
||||
return
|
||||
implementation = self._component.implementation
|
||||
assert isinstance(implementation, EquationImplementation)
|
||||
editor = self._editor_for_section(section)
|
||||
text = "\n".join(getattr(implementation, section))
|
||||
if editor.toPlainText() == text:
|
||||
return
|
||||
self._loading = True
|
||||
editor.setPlainText(text)
|
||||
self._loading = False
|
||||
|
||||
def _text_changed(self) -> None:
|
||||
if self._loading or self._component is None:
|
||||
return
|
||||
|
||||
editor = self.sender()
|
||||
section = self._section_for_editor(editor)
|
||||
self.equation_text_change_requested.emit(self._component, section, editor.toPlainText().splitlines(), self._edit_id)
|
||||
self._edit_timer.start()
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched in self._text_editors() and event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
|
||||
self.finish_text_edit()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _text_editors(self) -> tuple:
|
||||
return (self.ui.declarationsTextEdit, self.ui.initialEquationsTextEdit, self.ui.equationsTextEdit)
|
||||
|
||||
def _section_for_editor(self, editor: QObject) -> str:
|
||||
if editor is self.ui.declarationsTextEdit:
|
||||
return "declarations"
|
||||
if editor is self.ui.initialEquationsTextEdit:
|
||||
return "initial_equations"
|
||||
return "equations"
|
||||
|
||||
def _editor_for_section(self, section: str):
|
||||
return {
|
||||
"declarations": self.ui.declarationsTextEdit,
|
||||
"initial_equations": self.ui.initialEquationsTextEdit,
|
||||
"equations": self.ui.equationsTextEdit,
|
||||
}[section]
|
||||
|
||||
def _params_changed(self) -> None:
|
||||
if self._loading or self._component is None:
|
||||
return
|
||||
self._component.parameters = self.param_editor.params()
|
||||
self.component_changed.emit(self._component)
|
||||
|
||||
def _ports_changed(self) -> None:
|
||||
if self._loading or self._component is None:
|
||||
return
|
||||
ports = self.port_editor.ports()
|
||||
metadata = self.port_editor.port_metadata()
|
||||
if ports != self._component.interface.ports:
|
||||
self._component.interface.ports = ports
|
||||
self.component_changed.emit(self._component)
|
||||
if metadata != self._port_metadata:
|
||||
self._port_metadata = metadata
|
||||
self.port_metadata_change_requested.emit(self._component, metadata)
|
||||
|
||||
def refresh_port_metadata(self, port_metadata: dict[PortID, PortMetadata]) -> None:
|
||||
if port_metadata == self._port_metadata:
|
||||
return
|
||||
self._port_metadata = port_metadata
|
||||
if self._component is not None:
|
||||
self.port_editor.set_ports(self._component.interface.ports, port_metadata)
|
||||
|
||||
def _replace_placeholder(self, placeholder: QWidget, editor: QWidget) -> None:
|
||||
self.ui.verticalLayout_2.replaceWidget(placeholder, editor)
|
||||
placeholder.hide()
|
||||
placeholder.deleteLater()
|
||||
|
||||
def _set_editors_enabled(self, enabled: bool) -> None:
|
||||
self.ui.declarationsTextEdit.setEnabled(enabled)
|
||||
self.ui.initialEquationsTextEdit.setEnabled(enabled)
|
||||
self.ui.equationsTextEdit.setEnabled(enabled)
|
||||
self.param_editor.setEnabled(enabled)
|
||||
self.port_editor.setEnabled(enabled)
|
||||
853
src/bedit_gui/views/graph_editor_widget.py
Normal file
853
src/bedit_gui/views/graph_editor_widget.py
Normal file
@@ -0,0 +1,853 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from itertools import pairwise
|
||||
from math import hypot
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QTimer, Qt, Signal
|
||||
from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget
|
||||
|
||||
from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
|
||||
from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon, PortMetadata
|
||||
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
|
||||
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
|
||||
from bedit_gui.utils.icon import get_natural_icon_size, get_pixmap_bounding_box, render_icon
|
||||
|
||||
GRID_SPACING = 64
|
||||
SCENE_SIZE = 10000
|
||||
MIN_ZOOM = 0.2
|
||||
MAX_ZOOM = 4.0
|
||||
ZOOM_STEP = 1.15
|
||||
ZOOM_TO_FIT_PADDING = 32.0
|
||||
COMPONENT_LABEL_FONT_SIZE = 24.0
|
||||
FALLBACK_COMPONENT_SPACING = 128
|
||||
CONNECTION_WIDTH = 4.0
|
||||
BOND_CONNECTION_COLOR = "#000000"
|
||||
SIGNAL_CONNECTION_COLOR = "#00007f"
|
||||
BOND_CONNECTION_BOUNDING_BOX_SPACING = 16.0
|
||||
SIGNAL_CONNECTION_BOUNDING_BOX_SPACING = 0.0
|
||||
CONNECTION_STRAIGHTEN_TOLERANCE = 8.0
|
||||
ARROW_LENGTH = 32.0
|
||||
ARROW_HALF_WIDTH = 16.0
|
||||
SIGNAL_ARROW_LENGTH = ARROW_LENGTH / 2
|
||||
SIGNAL_ARROW_HALF_WIDTH = ARROW_HALF_WIDTH / 2
|
||||
CAUSALITY_TICK_HALF_LENGTH = 16.0
|
||||
CONNECTION_ANNOTATION_FONT_SIZE = 18.0
|
||||
CONNECTION_ANNOTATION_BACK_OFFSET = 24.0
|
||||
CONNECTION_ANNOTATION_SIDE_OFFSET = 14.0
|
||||
|
||||
|
||||
class GraphEditorMode(Enum):
|
||||
NORMAL = "normal"
|
||||
CONNECTION = "connection"
|
||||
|
||||
|
||||
class GraphGraphicsScene(QGraphicsScene):
|
||||
"""Graph canvas with a lightweight dotted-line grid."""
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
|
||||
painter.fillRect(rect, QColor("white"))
|
||||
pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine)
|
||||
painter.setPen(pen)
|
||||
|
||||
scene_rect = self.sceneRect()
|
||||
left = int(rect.left()) - int(rect.left()) % GRID_SPACING
|
||||
top = int(rect.top()) - int(rect.top()) % GRID_SPACING
|
||||
x = left
|
||||
while x <= rect.right():
|
||||
painter.drawLine(x, scene_rect.top(), x, scene_rect.bottom())
|
||||
x += GRID_SPACING
|
||||
y = top
|
||||
while y <= rect.bottom():
|
||||
painter.drawLine(scene_rect.left(), y, scene_rect.right(), y)
|
||||
y += GRID_SPACING
|
||||
|
||||
|
||||
class GraphConnectionItem(QGraphicsPathItem):
|
||||
"""A routed connection with a full signal arrow or half bond arrow."""
|
||||
|
||||
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None, connection_id: ConnectionID | None = None, editor: GraphEditorWidget | None = None) -> None:
|
||||
super().__init__()
|
||||
self.connection_id = connection_id
|
||||
self.editor = editor
|
||||
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
|
||||
color = QColor(BOND_CONNECTION_COLOR if half_arrow else SIGNAL_CONNECTION_COLOR)
|
||||
pen = QPen(color, CONNECTION_WIDTH)
|
||||
self.setPen(pen)
|
||||
self.setBrush(QBrush(color))
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setZValue(-1)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
if self.editor is None or self.connection_id is None:
|
||||
return
|
||||
if not self.isSelected():
|
||||
self.scene().clearSelection()
|
||||
self.setSelected(True)
|
||||
menu = QMenu(self.editor)
|
||||
add_point = menu.addAction("Add Point")
|
||||
delete_connection = menu.addAction("Delete Connection")
|
||||
selected = menu.exec(event.screenPos())
|
||||
if selected is add_point:
|
||||
self.editor.add_connection_point(self.connection_id, event.scenePos())
|
||||
elif selected is delete_connection:
|
||||
self.editor.delete_selected_connections()
|
||||
event.accept()
|
||||
|
||||
@staticmethod
|
||||
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
|
||||
path = QPainterPath(QPointF(*points[0]))
|
||||
for point in points[1:]:
|
||||
path.lineTo(QPointF(*point))
|
||||
|
||||
target = QPointF(*points[-1])
|
||||
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
|
||||
if previous is None:
|
||||
return path
|
||||
dx = target.x() - previous.x()
|
||||
dy = target.y() - previous.y()
|
||||
length = hypot(dx, dy)
|
||||
arrow_length = ARROW_LENGTH if half_arrow else SIGNAL_ARROW_LENGTH
|
||||
arrow_half_width = ARROW_HALF_WIDTH if half_arrow else SIGNAL_ARROW_HALF_WIDTH
|
||||
back_x = target.x() - arrow_length * dx / length
|
||||
back_y = target.y() - arrow_length * dy / length
|
||||
perpendicular_x = -arrow_half_width * dy / length
|
||||
perpendicular_y = arrow_half_width * dx / length
|
||||
path.moveTo(target)
|
||||
path.lineTo(back_x + perpendicular_x, back_y + perpendicular_y)
|
||||
if not half_arrow:
|
||||
path.lineTo(back_x - perpendicular_x, back_y - perpendicular_y)
|
||||
path.closeSubpath()
|
||||
if tick_at_source is not None:
|
||||
GraphConnectionItem._add_causality_tick(path, points, tick_at_source)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _add_causality_tick(path: QPainterPath, points: list[tuple[float, float]], at_source: bool) -> None:
|
||||
if at_source:
|
||||
endpoint = QPointF(*points[0])
|
||||
neighbor = next((QPointF(*point) for point in points[1:] if QPointF(*point) != endpoint), None)
|
||||
else:
|
||||
endpoint = QPointF(*points[-1])
|
||||
neighbor = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != endpoint), None)
|
||||
if neighbor is None:
|
||||
return
|
||||
dx = neighbor.x() - endpoint.x()
|
||||
dy = neighbor.y() - endpoint.y()
|
||||
length = hypot(dx, dy)
|
||||
perpendicular_x = -CAUSALITY_TICK_HALF_LENGTH * dy / length
|
||||
perpendicular_y = CAUSALITY_TICK_HALF_LENGTH * dx / length
|
||||
path.moveTo(endpoint.x() - perpendicular_x, endpoint.y() - perpendicular_y)
|
||||
path.lineTo(endpoint.x() + perpendicular_x, endpoint.y() + perpendicular_y)
|
||||
|
||||
|
||||
class GraphComponentItem(QGraphicsPixmapItem):
|
||||
def __init__(self, component_id: ComponentID, pixmap, editor: GraphEditorWidget) -> None:
|
||||
super().__init__(pixmap)
|
||||
self.component_id = component_id
|
||||
self.editor = editor
|
||||
self._drag_start = QPointF()
|
||||
self._dragging = False
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.component_id in self.editor._component_drag_starts and isinstance(value, QPointF):
|
||||
grid_size = self.editor.snap_to_grid_size
|
||||
value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size)
|
||||
result = super().itemChange(change, value)
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
self.editor.refresh_connections()
|
||||
return result
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.editor.mode is GraphEditorMode.CONNECTION:
|
||||
event.accept()
|
||||
return
|
||||
self._drag_start = QPointF(self.pos())
|
||||
self._dragging = True
|
||||
super().mousePressEvent(event)
|
||||
self.editor.begin_component_move()
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self.editor.mode is GraphEditorMode.CONNECTION:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.editor.choose_connection_component(self.component_id, event.screenPos())
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
self._dragging = False
|
||||
self.editor.finish_component_moves()
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.editor.open_component(self.component_id)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
if not self.isSelected():
|
||||
self.scene().clearSelection()
|
||||
self.setSelected(True)
|
||||
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
|
||||
event.accept()
|
||||
|
||||
|
||||
class GraphComponentLabelItem(QGraphicsTextItem):
|
||||
def __init__(self, component_id: ComponentID, text: str, label: GraphComponentLabel, component_item: GraphComponentItem, editor: GraphEditorWidget) -> None:
|
||||
super().__init__(text, component_item)
|
||||
self.component_id = component_id
|
||||
self.editor = editor
|
||||
self._dragging = False
|
||||
self._drag_start = label.relative_position
|
||||
font = self.font()
|
||||
font.setItalic(True)
|
||||
font.setPointSizeF(COMPONENT_LABEL_FONT_SIZE)
|
||||
self.setFont(font)
|
||||
self.setDefaultTextColor(QColor("#202020"))
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
self.set_relative_position(label.relative_position)
|
||||
|
||||
def relative_position(self) -> tuple[int, int]:
|
||||
component_bounds = self.editor._component_bounds[self.component_id]
|
||||
return round(self.pos().x() + self.boundingRect().width() / 2), round(self.pos().y() - component_bounds.bottom())
|
||||
|
||||
def set_relative_position(self, position: tuple[int, int]) -> None:
|
||||
component_bounds = self.editor._component_bounds[self.component_id]
|
||||
self.setPos(position[0] - self.boundingRect().width() / 2, component_bounds.bottom() + position[1])
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
|
||||
component_bounds = self.editor._component_bounds[self.component_id]
|
||||
size = self.editor.snap_to_grid_size
|
||||
relative_x = value.x() + self.boundingRect().width() / 2
|
||||
relative_y = value.y() - component_bounds.bottom()
|
||||
value = QPointF(round(relative_x / size) * size - self.boundingRect().width() / 2, component_bounds.bottom() + round(relative_y / size) * size)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
self._drag_start = self.relative_position()
|
||||
self._dragging = True
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
super().mouseReleaseEvent(event)
|
||||
self._dragging = False
|
||||
position = self.relative_position()
|
||||
self.set_relative_position(position)
|
||||
if position != self._drag_start:
|
||||
self.editor.finish_component_label_move(self.component_id, position)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
|
||||
event.accept()
|
||||
|
||||
|
||||
class GraphConnectionPointItem(QGraphicsEllipseItem):
|
||||
def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None:
|
||||
radius = CONNECTION_WIDTH
|
||||
super().__init__(-radius, -radius, radius * 2, radius * 2)
|
||||
self.connection_id = connection_id
|
||||
self.index = index
|
||||
self.editor = editor
|
||||
self._drag_start = QPointF()
|
||||
self._dragging = False
|
||||
self.setPos(*position)
|
||||
self.setPen(QPen(Qt.PenStyle.NoPen))
|
||||
self.setBrush(QBrush(QColor("#202020")))
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
|
||||
size = self.editor.snap_to_grid_size
|
||||
value = QPointF(round(value.x() / size) * size, round(value.y() / size) * size)
|
||||
result = super().itemChange(change, value)
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
self.editor.refresh_connections()
|
||||
return result
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
self._drag_start = QPointF(self.pos())
|
||||
self._dragging = True
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
super().mouseReleaseEvent(event)
|
||||
self._dragging = False
|
||||
position = (round(self.pos().x()), round(self.pos().y()))
|
||||
self.setPos(*position)
|
||||
if self.pos() != self._drag_start:
|
||||
self.editor.finish_connection_point_move(self.connection_id)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
menu = QMenu(self.editor)
|
||||
delete_point = menu.addAction("Delete Point")
|
||||
if menu.exec(event.screenPos()) is delete_point:
|
||||
self.editor.delete_connection_point(self.connection_id, self.index)
|
||||
event.accept()
|
||||
|
||||
|
||||
class GraphEditorWidget(QWidget):
|
||||
component_moves_requested = Signal(object, object)
|
||||
component_context_menu_requested = Signal(object, object)
|
||||
component_open_requested = Signal(object)
|
||||
component_label_move_requested = Signal(object, object, object)
|
||||
connection_points_change_requested = Signal(object, object, object, str)
|
||||
connection_add_requested = Signal(object, object)
|
||||
connections_delete_requested = Signal(object, object)
|
||||
component_drop_requested = Signal(object, object, object)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.ui = Ui_graphEditorWidget()
|
||||
self.ui.setupUi(self)
|
||||
self._component: Component | None = None
|
||||
self._graph = Graph()
|
||||
self._icons: dict[ComponentID, Icon] = {}
|
||||
self._port_metadata: dict[PortID, PortMetadata] = {}
|
||||
self._mode = GraphEditorMode.NORMAL
|
||||
self._connection_start: ComponentID | None = None
|
||||
self._connection_preview: QGraphicsPathItem | None = None
|
||||
self._component_items: dict[ComponentID, GraphComponentItem] = {}
|
||||
self._component_drag_starts: dict[ComponentID, tuple[int, int]] = {}
|
||||
self._component_bounds: dict[ComponentID, QRectF] = {}
|
||||
self._component_label_items: dict[ComponentID, GraphComponentLabelItem] = {}
|
||||
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
|
||||
self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {}
|
||||
self._connection_annotation_items: dict[ConnectionID, QGraphicsTextItem] = {}
|
||||
self.set_snap_to_grid_size(snap_to_grid_size)
|
||||
self.scene = GraphGraphicsScene(self)
|
||||
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
|
||||
self.ui.graphicsView.setScene(self.scene)
|
||||
self.ui.graphicsView.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.ui.graphicsView.viewport().installEventFilter(self)
|
||||
self.ui.graphicsView.viewport().setAcceptDrops(True)
|
||||
self._mode_actions = QActionGroup(self)
|
||||
self._mode_actions.setExclusive(True)
|
||||
self._mode_actions.addAction(self.ui.actionMouseMode)
|
||||
self._mode_actions.addAction(self.ui.actionConnectionMode)
|
||||
self.ui.actionMouseMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.NORMAL))
|
||||
self.ui.actionConnectionMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.CONNECTION))
|
||||
self.ui.actionMouseMode.setChecked(True)
|
||||
self._mode_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self)
|
||||
self._mode_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
self._mode_shortcut.activated.connect(self.toggle_mode)
|
||||
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
|
||||
self.ui.graphicsView.viewport().setMouseTracking(True)
|
||||
self.ui.graphicsView.centerOn(0, 0)
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int:
|
||||
return self._snap_to_grid_size
|
||||
|
||||
def set_snap_to_grid_size(self, size: int) -> None:
|
||||
if size < 1:
|
||||
raise ValueError("snap-to-grid size must be positive")
|
||||
self._snap_to_grid_size = size
|
||||
|
||||
@property
|
||||
def mode(self) -> GraphEditorMode:
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: GraphEditorMode) -> None:
|
||||
if mode is self._mode:
|
||||
return
|
||||
self._mode = mode
|
||||
self.ui.actionMouseMode.setChecked(mode is GraphEditorMode.NORMAL)
|
||||
self.ui.actionConnectionMode.setChecked(mode is GraphEditorMode.CONNECTION)
|
||||
self._clear_connection_start()
|
||||
component = self._component
|
||||
if component is not None:
|
||||
self.set_component(component, self._graph, self._icons, self._port_metadata)
|
||||
|
||||
def toggle_mode(self) -> None:
|
||||
self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL)
|
||||
|
||||
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
if component is not None and not isinstance(component.implementation, GraphImplementation):
|
||||
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
|
||||
component_changed = component is not self._component
|
||||
self._clear_connection_start()
|
||||
self._component = component
|
||||
self._graph = graph or Graph()
|
||||
self._icons = icons or {}
|
||||
self._port_metadata = port_metadata or {}
|
||||
self._component_drag_starts = {}
|
||||
self._component_items = {}
|
||||
self._component_bounds = {}
|
||||
self._component_label_items = {}
|
||||
self._connection_items = {}
|
||||
self._connection_point_items = {}
|
||||
self._connection_annotation_items = {}
|
||||
self.scene.clear()
|
||||
if component is None:
|
||||
return
|
||||
|
||||
graph = self._graph
|
||||
icons = self._icons
|
||||
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
|
||||
for component_id, child in component.implementation.graph.components.items():
|
||||
icon = icons.get(component_id, Icon())
|
||||
icon_size = get_natural_icon_size(icon)
|
||||
pixmap = render_icon(icon, child.interface.ports, icon_size, render_ports=self._mode is GraphEditorMode.CONNECTION).pixmap(icon_size)
|
||||
item = GraphComponentItem(component_id, pixmap, self)
|
||||
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||
item.setPos(*positions[component_id])
|
||||
item.setToolTip(child.name)
|
||||
bounds = get_pixmap_bounding_box(pixmap)
|
||||
self._component_items[component_id] = item
|
||||
self._component_bounds[component_id] = bounds.translated(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||
self.scene.addItem(item)
|
||||
|
||||
for component_id, child in component.implementation.graph.components.items():
|
||||
label = graph.component_labels.get(component_id, GraphComponentLabel())
|
||||
if label.visible:
|
||||
self._create_component_label_item(component_id, child.name, label)
|
||||
|
||||
for connection_id, connection in component.implementation.graph.connections.items():
|
||||
if not isinstance(connection, (SignalConnection, BondConnection)):
|
||||
continue
|
||||
tick_at_source = None
|
||||
if isinstance(connection, BondConnection):
|
||||
if connection.causality is BondCausality.EFFORT_OUT:
|
||||
tick_at_source = False
|
||||
elif connection.causality is BondCausality.FLOW_OUT:
|
||||
tick_at_source = True
|
||||
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source, connection_id=connection_id, editor=self)
|
||||
connection_item.setData(0, str(connection_id))
|
||||
self._connection_items[connection_id] = connection_item
|
||||
self.scene.addItem(connection_item)
|
||||
annotation = self._port_metadata.get(connection.target, PortMetadata()).connection_annotation if isinstance(connection, SignalConnection) else None
|
||||
if annotation:
|
||||
annotation_item = QGraphicsTextItem(annotation)
|
||||
font = annotation_item.font()
|
||||
font.setBold(True)
|
||||
font.setPointSizeF(CONNECTION_ANNOTATION_FONT_SIZE)
|
||||
annotation_item.setFont(font)
|
||||
annotation_item.setDefaultTextColor(QColor(SIGNAL_CONNECTION_COLOR))
|
||||
annotation_item.setZValue(1)
|
||||
self._connection_annotation_items[connection_id] = annotation_item
|
||||
self.scene.addItem(annotation_item)
|
||||
visual_connection = graph.connections.get(connection_id)
|
||||
self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else [])
|
||||
self.refresh_connections()
|
||||
if component_changed:
|
||||
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
|
||||
|
||||
def refresh_connections(self) -> None:
|
||||
component = self._component
|
||||
if component is None or not self._connection_items:
|
||||
return
|
||||
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
|
||||
for connection_id, item in self._connection_items.items():
|
||||
connection = component.implementation.graph.connections.get(connection_id)
|
||||
if connection is None:
|
||||
continue
|
||||
source_component = port_owners.get(connection.source)
|
||||
target_component = port_owners.get(connection.target)
|
||||
if source_component is None or target_component is None:
|
||||
continue
|
||||
source_position = self._item_position(source_component)
|
||||
target_position = self._item_position(target_component)
|
||||
point_items = self._connection_point_items.get(connection_id, [])
|
||||
points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position]
|
||||
points = self._straighten_direct_connection(points)
|
||||
source_bounds = self._component_bounds[source_component].translated(*source_position)
|
||||
target_bounds = self._component_bounds[target_component].translated(*target_position)
|
||||
spacing = BOND_CONNECTION_BOUNDING_BOX_SPACING if isinstance(connection, BondConnection) else SIGNAL_CONNECTION_BOUNDING_BOX_SPACING
|
||||
points = self._clip_connection(points, source_bounds, target_bounds, spacing)
|
||||
tick_at_source = None
|
||||
if isinstance(connection, BondConnection):
|
||||
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
|
||||
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
|
||||
annotation_item = self._connection_annotation_items.get(connection_id)
|
||||
if annotation_item is not None:
|
||||
self._position_connection_annotation(annotation_item, points)
|
||||
|
||||
@staticmethod
|
||||
def _position_connection_annotation(item: QGraphicsTextItem, points: list[tuple[float, float]]) -> None:
|
||||
target = QPointF(*points[-1])
|
||||
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
|
||||
if previous is None:
|
||||
return
|
||||
dx = target.x() - previous.x()
|
||||
dy = target.y() - previous.y()
|
||||
length = hypot(dx, dy)
|
||||
x = target.x() - CONNECTION_ANNOTATION_BACK_OFFSET * dx / length - CONNECTION_ANNOTATION_SIDE_OFFSET * dy / length
|
||||
y = target.y() - CONNECTION_ANNOTATION_BACK_OFFSET * dy / length + CONNECTION_ANNOTATION_SIDE_OFFSET * dx / length
|
||||
bounds = item.boundingRect()
|
||||
item.setPos(x - bounds.width() / 2, y - bounds.height() / 2)
|
||||
|
||||
def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None:
|
||||
points = self._connection_metadata_points(connection_id)
|
||||
position = self._snap_position(scene_position)
|
||||
index = self._nearest_segment_index(points, position) + 1
|
||||
points.insert(index, position)
|
||||
self._request_connection_points_change(connection_id, points, "Add connection point")
|
||||
|
||||
def delete_connection_point(self, connection_id: ConnectionID, index: int) -> None:
|
||||
points = self._connection_metadata_points(connection_id)
|
||||
if 0 < index < len(points) - 1:
|
||||
points.pop(index)
|
||||
self._request_connection_points_change(connection_id, points, "Delete connection point")
|
||||
|
||||
def finish_connection_point_move(self, connection_id: ConnectionID) -> None:
|
||||
self._request_connection_points_change(connection_id, self._connection_metadata_points(connection_id), "Move connection point")
|
||||
|
||||
def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||
if points is None:
|
||||
self._graph.connections.pop(connection_id, None)
|
||||
for item in self._connection_point_items.pop(connection_id, []):
|
||||
self.scene.removeItem(item)
|
||||
connection_item = self._connection_items.pop(connection_id, None)
|
||||
if connection_item is not None:
|
||||
self.scene.removeItem(connection_item)
|
||||
annotation_item = self._connection_annotation_items.pop(connection_id, None)
|
||||
if annotation_item is not None:
|
||||
self.scene.removeItem(annotation_item)
|
||||
return
|
||||
self._graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||
interior_points = points[1:-1]
|
||||
items = self._connection_point_items.get(connection_id, [])
|
||||
if len(items) == len(interior_points):
|
||||
for item, position in zip(items, interior_points):
|
||||
item.setPos(*position)
|
||||
else:
|
||||
for item in items:
|
||||
self.scene.removeItem(item)
|
||||
self._create_connection_point_items(connection_id, interior_points)
|
||||
self.refresh_connections()
|
||||
|
||||
def _create_connection_point_items(self, connection_id: ConnectionID, positions: list[tuple[int, int]]) -> None:
|
||||
items = [GraphConnectionPointItem(connection_id, index, position, self) for index, position in enumerate(positions, 1)]
|
||||
self._connection_point_items[connection_id] = items
|
||||
for item in items:
|
||||
self.scene.addItem(item)
|
||||
|
||||
def _connection_metadata_points(self, connection_id: ConnectionID) -> list[tuple[int, int]]:
|
||||
component = self._component
|
||||
if component is None:
|
||||
return []
|
||||
connection = component.implementation.graph.connections[connection_id]
|
||||
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
|
||||
source = self._item_position(port_owners[connection.source])
|
||||
target = self._item_position(port_owners[connection.target])
|
||||
interior = [(round(item.pos().x()), round(item.pos().y())) for item in self._connection_point_items.get(connection_id, [])]
|
||||
return [(round(source[0]), round(source[1])), *interior, (round(target[0]), round(target[1]))]
|
||||
|
||||
def _request_connection_points_change(self, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||
if self._component is not None:
|
||||
self.connection_points_change_requested.emit(self._component, connection_id, points, text)
|
||||
|
||||
def _snap_position(self, position: QPointF) -> tuple[int, int]:
|
||||
size = self.snap_to_grid_size
|
||||
return round(position.x() / size) * size, round(position.y() / size) * size
|
||||
|
||||
@staticmethod
|
||||
def _nearest_segment_index(points: list[tuple[int, int]], position: tuple[int, int]) -> int:
|
||||
best_index = 0
|
||||
best_distance = float("inf")
|
||||
for index, (start, end) in enumerate(pairwise(points)):
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
length_squared = dx * dx + dy * dy
|
||||
ratio = 0.0 if not length_squared else max(0.0, min(1.0, ((position[0] - start[0]) * dx + (position[1] - start[1]) * dy) / length_squared))
|
||||
closest_x = start[0] + ratio * dx
|
||||
closest_y = start[1] + ratio * dy
|
||||
distance = (position[0] - closest_x) ** 2 + (position[1] - closest_y) ** 2
|
||||
if distance < best_distance:
|
||||
best_index = index
|
||||
best_distance = distance
|
||||
return best_index
|
||||
|
||||
def begin_component_move(self) -> None:
|
||||
self._component_drag_starts = {item.component_id: (round(item.pos().x()), round(item.pos().y())) for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)}
|
||||
|
||||
def finish_component_moves(self) -> None:
|
||||
starts = self._component_drag_starts
|
||||
self._component_drag_starts = {}
|
||||
if self._component is None:
|
||||
return
|
||||
positions = {}
|
||||
for component_id, old_position in starts.items():
|
||||
item = self._component_items[component_id]
|
||||
position = (round(item.pos().x()), round(item.pos().y()))
|
||||
item.setPos(*position)
|
||||
if position != old_position:
|
||||
positions[component_id] = position
|
||||
if positions:
|
||||
self.component_moves_requested.emit(self._component, positions)
|
||||
|
||||
def finish_component_label_move(self, component_id: ComponentID, relative_position: tuple[int, int]) -> None:
|
||||
if self._component is not None:
|
||||
self.component_label_move_requested.emit(self._component, component_id, relative_position)
|
||||
|
||||
def component_label_visible(self, component_id: ComponentID) -> bool:
|
||||
return self._graph.component_labels.get(component_id, GraphComponentLabel()).visible
|
||||
|
||||
def set_component_label(self, component_id: ComponentID, label: GraphComponentLabel | None) -> None:
|
||||
if label is None:
|
||||
self._graph.component_labels.pop(component_id, None)
|
||||
label = GraphComponentLabel()
|
||||
else:
|
||||
self._graph.component_labels[component_id] = label
|
||||
item = self._component_label_items.pop(component_id, None)
|
||||
if item is not None:
|
||||
item.setParentItem(None)
|
||||
self.scene.removeItem(item)
|
||||
component = self._component
|
||||
if component is not None and label.visible and component_id in component.implementation.graph.components:
|
||||
self._create_component_label_item(component_id, component.implementation.graph.components[component_id].name, label)
|
||||
|
||||
def _create_component_label_item(self, component_id: ComponentID, text: str, label: GraphComponentLabel) -> None:
|
||||
item = GraphComponentLabelItem(component_id, text, label, self._component_items[component_id], self)
|
||||
self._component_label_items[component_id] = item
|
||||
|
||||
def set_component_position(self, component_id: ComponentID, position: tuple[int, int] | None) -> None:
|
||||
item = self._component_items.get(component_id)
|
||||
if item is None:
|
||||
return
|
||||
if position is None:
|
||||
self._graph.component_positions.pop(component_id, None)
|
||||
index = list(self._component_items).index(component_id)
|
||||
position = (index * FALLBACK_COMPONENT_SPACING, 0)
|
||||
else:
|
||||
self._graph.component_positions[component_id] = position
|
||||
item.setPos(*position)
|
||||
self.refresh_connections()
|
||||
|
||||
def _item_position(self, component_id: ComponentID) -> tuple[float, float]:
|
||||
position = self._component_items[component_id].pos()
|
||||
return position.x(), position.y()
|
||||
|
||||
@staticmethod
|
||||
def _straighten_direct_connection(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
if len(points) != 2:
|
||||
return points
|
||||
source, target = points
|
||||
dx = target[0] - source[0]
|
||||
dy = target[1] - source[1]
|
||||
if abs(dx) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dx) < abs(dy):
|
||||
x = (source[0] + target[0]) / 2
|
||||
return [(x, source[1]), (x, target[1])]
|
||||
if abs(dy) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dy) < abs(dx):
|
||||
y = (source[1] + target[1]) / 2
|
||||
return [(source[0], y), (target[0], y)]
|
||||
return points
|
||||
|
||||
@staticmethod
|
||||
def _clip_connection(points: list[tuple[float, float]], source_bounds: QRectF, target_bounds: QRectF, spacing: float) -> list[tuple[float, float]]:
|
||||
source = points[0]
|
||||
target = points[-1]
|
||||
source_direction = next((point for point in points[1:] if point != source), None)
|
||||
target_direction = next((point for point in reversed(points[:-1]) if point != target), None)
|
||||
if source_direction is None or target_direction is None:
|
||||
return points
|
||||
clipped = list(points)
|
||||
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source, source_direction, spacing)
|
||||
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target, target_direction, spacing)
|
||||
return clipped
|
||||
|
||||
@staticmethod
|
||||
def _bounding_box_edge(bounds: QRectF, origin: tuple[float, float], toward: tuple[float, float], spacing: float) -> tuple[float, float]:
|
||||
bounds = bounds.adjusted(-spacing, -spacing, spacing, spacing)
|
||||
dx = toward[0] - origin[0]
|
||||
dy = toward[1] - origin[1]
|
||||
horizontal_scale = (bounds.right() - origin[0]) / dx if dx > 0 else (bounds.left() - origin[0]) / dx if dx < 0 else float("inf")
|
||||
vertical_scale = (bounds.bottom() - origin[1]) / dy if dy > 0 else (bounds.top() - origin[1]) / dy if dy < 0 else float("inf")
|
||||
scale = min(horizontal_scale, vertical_scale)
|
||||
return origin[0] + dx * scale, origin[1] + dy * scale
|
||||
|
||||
def component(self) -> Component | None:
|
||||
return self._component
|
||||
|
||||
def selected_component_ids(self) -> list[ComponentID]:
|
||||
return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)]
|
||||
|
||||
def selected_connection_ids(self) -> list[ConnectionID]:
|
||||
return [item.connection_id for item in self.scene.selectedItems() if isinstance(item, GraphConnectionItem) and item.connection_id is not None]
|
||||
|
||||
def delete_selected_connections(self) -> None:
|
||||
if self._component is not None:
|
||||
self.connections_delete_requested.emit(self._component, self.selected_connection_ids())
|
||||
|
||||
def open_component(self, component_id: ComponentID) -> None:
|
||||
self._clear_connection_start()
|
||||
self.component_open_requested.emit(component_id)
|
||||
|
||||
def choose_connection_component(self, component_id: ComponentID, screen_position) -> None:
|
||||
if self._component is None or self._mode is not GraphEditorMode.CONNECTION:
|
||||
return
|
||||
if self._connection_start is None:
|
||||
self.scene.clearSelection()
|
||||
self._component_items[component_id].setSelected(True)
|
||||
self._connection_start = component_id
|
||||
self._connection_preview = QGraphicsPathItem()
|
||||
self._connection_preview.setPen(QPen(QColor("#606060"), CONNECTION_WIDTH, Qt.PenStyle.DashLine))
|
||||
self._connection_preview.setZValue(-0.5)
|
||||
self.scene.addItem(self._connection_preview)
|
||||
return
|
||||
start = self._connection_start
|
||||
self._clear_connection_start()
|
||||
self.scene.clearSelection()
|
||||
if start == component_id:
|
||||
return
|
||||
options = self._connection_options(start, component_id)
|
||||
if len(options) == 1:
|
||||
self.connection_add_requested.emit(self._component, options[0][1])
|
||||
return
|
||||
if not options:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
actions = []
|
||||
for label, connection, _preferred in options:
|
||||
action = menu.addAction(label)
|
||||
actions.append((action, connection))
|
||||
menu.setActiveAction(actions[0][0])
|
||||
selected = menu.exec(screen_position)
|
||||
for action, connection in actions:
|
||||
if selected is action:
|
||||
self.connection_add_requested.emit(self._component, connection)
|
||||
break
|
||||
|
||||
def _update_connection_preview(self, mouse_position: QPointF) -> None:
|
||||
if self._connection_start is None or self._connection_preview is None:
|
||||
return
|
||||
source = self._item_position(self._connection_start)
|
||||
if mouse_position == QPointF(*source):
|
||||
self._connection_preview.setPath(QPainterPath(mouse_position))
|
||||
return
|
||||
bounds = self._component_bounds[self._connection_start].translated(*source)
|
||||
spacing = max(BOND_CONNECTION_BOUNDING_BOX_SPACING, SIGNAL_CONNECTION_BOUNDING_BOX_SPACING)
|
||||
start = self._bounding_box_edge(bounds, source, (mouse_position.x(), mouse_position.y()), spacing)
|
||||
path = QPainterPath(QPointF(*start))
|
||||
path.lineTo(mouse_position)
|
||||
self._connection_preview.setPath(path)
|
||||
|
||||
def _clear_connection_start(self) -> None:
|
||||
self._connection_start = None
|
||||
if self._connection_preview is not None and self._connection_preview.scene() is self.scene:
|
||||
self.scene.removeItem(self._connection_preview)
|
||||
self._connection_preview = None
|
||||
|
||||
def _connection_options(self, first_id: ComponentID, second_id: ComponentID) -> list[tuple[str, Connection, bool]]:
|
||||
if self._component is None:
|
||||
return []
|
||||
components = self._component.implementation.graph.components
|
||||
first = components[first_id]
|
||||
second = components[second_id]
|
||||
used_ports = {port_id for connection in self._component.implementation.graph.connections.values() for port_id in (connection.source, connection.target)}
|
||||
options = []
|
||||
for first_port_id, first_port in first.interface.ports.items():
|
||||
for second_port_id, second_port in second.interface.ports.items():
|
||||
if not self._compatible_ports(first_port, second_port) or not self._port_available(first_port_id, first_port, used_ports) or not self._port_available(second_port_id, second_port, used_ports):
|
||||
continue
|
||||
preferred = first_port.direction is SignalDirection.OUTPUT and second_port.direction is SignalDirection.INPUT
|
||||
reverse_preferred = second_port.direction is SignalDirection.OUTPUT and first_port.direction is SignalDirection.INPUT
|
||||
if reverse_preferred:
|
||||
source_id, source_port, source_name = second_port_id, second_port, second.name
|
||||
target_id, target_port, target_name = first_port_id, first_port, first.name
|
||||
else:
|
||||
source_id, source_port, source_name = first_port_id, first_port, first.name
|
||||
target_id, target_port, target_name = second_port_id, second_port, second.name
|
||||
connection_type = SignalConnection if isinstance(source_port, SignalPort) else BondConnection
|
||||
label = f"{source_name}.{source_port.name} → {target_name}.{target_port.name}"
|
||||
options.append((label, connection_type(source=source_id, target=target_id), preferred or reverse_preferred))
|
||||
return sorted(options, key=lambda option: not option[2])
|
||||
|
||||
@staticmethod
|
||||
def _compatible_ports(first: Port, second: Port) -> bool:
|
||||
if isinstance(first, SignalPort) and isinstance(second, SignalPort):
|
||||
return first.direction is not second.direction
|
||||
if isinstance(first, BondPort) and isinstance(second, BondPort):
|
||||
return not first.domain or not second.domain or first.domain == second.domain
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _port_available(port_id: PortID, port: Port, used_ports: set[PortID]) -> bool:
|
||||
if isinstance(port, SignalPort):
|
||||
return port.direction is SignalDirection.OUTPUT or port.multiplicity or port_id not in used_ports
|
||||
if isinstance(port, BondPort):
|
||||
return port.multiplicity or port_id not in used_ports
|
||||
return False
|
||||
|
||||
def paste_position(self) -> tuple[int, int]:
|
||||
viewport = self.ui.graphicsView.viewport()
|
||||
viewport_position = viewport.mapFromGlobal(QCursor.pos())
|
||||
if not viewport.rect().contains(viewport_position):
|
||||
viewport_position = viewport.rect().center()
|
||||
scene_position = self.ui.graphicsView.mapToScene(viewport_position)
|
||||
size = self.snap_to_grid_size
|
||||
return round(scene_position.x() / size) * size, round(scene_position.y() / size) * size
|
||||
|
||||
def zoom_to_fit(self) -> None:
|
||||
bounds = self.scene.itemsBoundingRect()
|
||||
if bounds.isEmpty():
|
||||
self.ui.graphicsView.resetTransform()
|
||||
self.ui.graphicsView.centerOn(0, 0)
|
||||
return
|
||||
bounds.adjust(-ZOOM_TO_FIT_PADDING, -ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING)
|
||||
self.ui.graphicsView.fitInView(bounds, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
current_zoom = self.ui.graphicsView.transform().m11()
|
||||
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, current_zoom))
|
||||
if target_zoom != current_zoom:
|
||||
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
|
||||
self.ui.graphicsView.centerOn(bounds.center())
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() in (QEvent.Type.DragEnter, QEvent.Type.DragMove):
|
||||
assert isinstance(event, (QDragEnterEvent, QDragMoveEvent))
|
||||
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
|
||||
event.setDropAction(Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
return True
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Drop:
|
||||
assert isinstance(event, QDropEvent)
|
||||
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
|
||||
try:
|
||||
payload = json.loads(bytes(event.mimeData().data(COMPONENTS_MIME)).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return True
|
||||
if isinstance(payload, dict):
|
||||
position = self._snap_position(self.ui.graphicsView.mapToScene(event.position().toPoint()))
|
||||
self.component_drop_requested.emit(self._component, payload, position)
|
||||
event.setDropAction(Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
return True
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.MouseMove:
|
||||
assert isinstance(event, QMouseEvent)
|
||||
self._update_connection_preview(self.ui.graphicsView.mapToScene(event.position().toPoint()))
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
|
||||
assert isinstance(event, QWheelEvent)
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
self._zoom(event)
|
||||
return True
|
||||
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
||||
self._scroll_horizontally(event)
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _zoom(self, event: QWheelEvent) -> None:
|
||||
delta = event.angleDelta().y() or event.pixelDelta().y()
|
||||
if not delta:
|
||||
return
|
||||
current_zoom = self.ui.graphicsView.transform().m11()
|
||||
requested_zoom = current_zoom * ZOOM_STEP ** (delta / 120)
|
||||
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, requested_zoom))
|
||||
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
|
||||
|
||||
def _scroll_horizontally(self, event: QWheelEvent) -> None:
|
||||
scrollbar = self.ui.graphicsView.horizontalScrollBar()
|
||||
pixel_delta = event.pixelDelta().y()
|
||||
distance = pixel_delta if pixel_delta else event.angleDelta().y() / 120 * scrollbar.singleStep() * 3
|
||||
scrollbar.setValue(scrollbar.value() - round(distance))
|
||||
354
src/bedit_gui/views/icon_editor_window.py
Normal file
354
src/bedit_gui/views/icon_editor_window.py
Normal file
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt, Signal
|
||||
from PySide6.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent, QShowEvent
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog, QGraphicsView, QMainWindow, QMessageBox, QWidget
|
||||
|
||||
from bedit_core.models import Port, PortID, SignalDirection
|
||||
from bedit_gui.models import Icon, Shape, ShapeID
|
||||
from bedit_gui.services import icon_files
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor
|
||||
from bedit_gui.views.icon_graphics_scene import EllipseCreationTool, IconGraphicsScene, LineCreationTool, RectangleCreationTool, TextCreationTool
|
||||
from bedit_gui.views.shape_options_dialog import ShapeOptionsDialog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChangeIconDraftCommand(QUndoCommand):
|
||||
def __init__(self, editor: IconEditorWindow, icon: Icon, text: str) -> None:
|
||||
super().__init__(text)
|
||||
self.editor = editor
|
||||
self.old_icon = editor.icon()
|
||||
self.new_icon = deepcopy(icon)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.editor._set_icon(self.new_icon)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.editor._set_icon(self.old_icon)
|
||||
|
||||
|
||||
class AddShapeCommand(QUndoCommand):
|
||||
def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, shape: Shape) -> None:
|
||||
super().__init__(f"Add {shape.type}")
|
||||
self.editor = editor
|
||||
self.shape_id = shape_id
|
||||
self.shape = deepcopy(shape)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.editor._add_shape(self.shape_id, self.shape)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.editor._remove_shape(self.shape_id)
|
||||
|
||||
|
||||
class ChangeShapeCommand(QUndoCommand):
|
||||
def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
|
||||
super().__init__(f"Change {new_shape.type}")
|
||||
self.editor = editor
|
||||
self.shape_id = shape_id
|
||||
self.old_shape = deepcopy(old_shape)
|
||||
self.new_shape = deepcopy(new_shape)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.editor._change_shape(self.shape_id, self.new_shape)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.editor._change_shape(self.shape_id, self.old_shape)
|
||||
|
||||
|
||||
class DeleteShapesCommand(QUndoCommand):
|
||||
def __init__(self, editor: IconEditorWindow, shapes: dict[ShapeID, Shape]) -> None:
|
||||
text = "Delete shape" if len(shapes) == 1 else "Delete shapes"
|
||||
super().__init__(text)
|
||||
self.editor = editor
|
||||
self.shapes = deepcopy(shapes)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.editor._remove_shapes(self.shapes)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.editor._restore_shapes(self.shapes)
|
||||
|
||||
|
||||
class MovePortCommand(QUndoCommand):
|
||||
def __init__(self, editor: IconEditorWindow, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
|
||||
super().__init__("Move port")
|
||||
self.editor = editor
|
||||
self.port_id = port_id
|
||||
self.old_position = old_position
|
||||
self.new_position = new_position
|
||||
|
||||
def redo(self) -> None:
|
||||
self.editor._move_port(self.port_id, self.new_position)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.editor._move_port(self.port_id, self.old_position)
|
||||
|
||||
|
||||
class IconEditorWindow(QMainWindow):
|
||||
"""Independent icon editing session with its own undo stack."""
|
||||
|
||||
saved = Signal(object)
|
||||
icon_changed = Signal(object)
|
||||
zoom_step = 1.2
|
||||
minimum_zoom = 0.1
|
||||
maximum_zoom = 10.0
|
||||
|
||||
def __init__(self, icon: Icon, ports: dict[PortID, Port], parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.ui = Ui_iconEditor()
|
||||
self.ui.setupUi(self)
|
||||
self.setWindowTitle("Icon Editor")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self._icon = deepcopy(icon)
|
||||
self._ports = deepcopy(ports)
|
||||
|
||||
self.scene = IconGraphicsScene(self)
|
||||
self.scene.set_ports(self._ports)
|
||||
self._ensure_port_positions(self._ports)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.scene.port_moved.connect(self._port_moved)
|
||||
self.scene.shape_created.connect(self._shape_created)
|
||||
self.scene.shape_changed.connect(self._shape_changed)
|
||||
self.scene.shape_options_requested.connect(self._show_shape_options)
|
||||
self.scene.tool_active_changed.connect(self._tool_active_changed)
|
||||
self.ui.graphicsView.setScene(self.scene)
|
||||
self.ui.graphicsView.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.ui.graphicsView.viewport().installEventFilter(self)
|
||||
self.ui.actionAdd_Rectangle.setCheckable(True)
|
||||
self.ui.actionAdd_Rectangle.triggered.connect(self._start_rectangle_tool)
|
||||
self.ui.actionAdd_Circle.setCheckable(True)
|
||||
self.ui.actionAdd_Circle.triggered.connect(self._start_ellipse_tool)
|
||||
self.ui.actionAdd_Text.setCheckable(True)
|
||||
self.ui.actionAdd_Text.triggered.connect(self._start_text_tool)
|
||||
self.ui.actionAdd_Line.setCheckable(True)
|
||||
self.ui.actionAdd_Line.triggered.connect(self._start_line_tool)
|
||||
self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
|
||||
self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool)
|
||||
self.delete_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Delete), self)
|
||||
self.delete_shortcut.activated.connect(self._delete_selected_shapes)
|
||||
self.zoom_in_shortcut = QShortcut(QKeySequence("Ctrl++"), self)
|
||||
self.zoom_in_alt_shortcut = QShortcut(QKeySequence("Ctrl+="), self)
|
||||
self.zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self)
|
||||
self.zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self)
|
||||
self.zoom_in_shortcut.activated.connect(self.zoom_in)
|
||||
self.zoom_in_alt_shortcut.activated.connect(self.zoom_in)
|
||||
self.zoom_out_shortcut.activated.connect(self.zoom_out)
|
||||
self.zoom_reset_shortcut.activated.connect(self.reset_zoom)
|
||||
|
||||
self.undo_stack = QUndoStack(self)
|
||||
self.ui.actionUndo.triggered.connect(self.undo_stack.undo)
|
||||
self.ui.actionRedo.triggered.connect(self.undo_stack.redo)
|
||||
self.ui.actionSave.triggered.connect(self.save)
|
||||
self.ui.actionSave_to_File.triggered.connect(self.save_to_file)
|
||||
self.ui.actionOpen_from_File.triggered.connect(self.open_from_file)
|
||||
self.ui.actionCancel.triggered.connect(self.close)
|
||||
self.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
|
||||
self.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
|
||||
self.undo_stack.undoTextChanged.connect(self._update_undo_text)
|
||||
self.undo_stack.redoTextChanged.connect(self._update_redo_text)
|
||||
self.ui.actionUndo.setEnabled(False)
|
||||
self.ui.actionRedo.setEnabled(False)
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
super().showEvent(event)
|
||||
self.fit_scene()
|
||||
|
||||
def icon(self) -> Icon:
|
||||
return deepcopy(self._icon)
|
||||
|
||||
def apply_change(self, icon: Icon, text: str = "Edit icon") -> None:
|
||||
self.undo_stack.push(ChangeIconDraftCommand(self, icon, text))
|
||||
|
||||
def save(self) -> None:
|
||||
self.saved.emit(self.icon())
|
||||
self.close()
|
||||
|
||||
def save_to_file(self) -> None:
|
||||
file_name, _ = QFileDialog.getSaveFileName(self, "Save Icon", "", "JSON files (*.json)")
|
||||
if not file_name:
|
||||
return
|
||||
path = file_name if file_name.lower().endswith(".json") else f"{file_name}.json"
|
||||
try:
|
||||
icon_files.save(self._icon, path)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not save icon to %s", path)
|
||||
QMessageBox.critical(self, "Could not save icon", str(exc))
|
||||
return
|
||||
logger.info("Saved icon to: %s", path)
|
||||
|
||||
def open_from_file(self) -> None:
|
||||
file_name, _ = QFileDialog.getOpenFileName(self, "Open Icon", "", "JSON files (*.json)")
|
||||
if not file_name:
|
||||
return
|
||||
try:
|
||||
icon = icon_files.load(file_name)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not open icon from %s", file_name)
|
||||
QMessageBox.critical(self, "Could not open icon", str(exc))
|
||||
return
|
||||
self.apply_change(icon, "Load icon from file")
|
||||
logger.info("Loaded icon from: %s", file_name)
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
self._zoom(self.zoom_step)
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
self._zoom(1 / self.zoom_step)
|
||||
|
||||
def reset_zoom(self) -> None:
|
||||
self.fit_scene()
|
||||
|
||||
def fit_scene(self) -> None:
|
||||
self.ui.graphicsView.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self.ui.graphicsView.viewport() and isinstance(event, QWheelEvent):
|
||||
if event.angleDelta().y() == 0:
|
||||
return True
|
||||
self.zoom_in() if event.angleDelta().y() > 0 else self.zoom_out()
|
||||
event.accept()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _zoom(self, factor: float) -> None:
|
||||
current = self.ui.graphicsView.transform().m11()
|
||||
target = max(self.minimum_zoom, min(self.maximum_zoom, current * factor))
|
||||
if target != current:
|
||||
factor = target / current
|
||||
self.ui.graphicsView.scale(factor, factor)
|
||||
|
||||
def _set_icon(self, icon: Icon) -> None:
|
||||
self._icon = deepcopy(icon)
|
||||
self._ensure_port_positions(self._ports)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _add_shape(self, shape_id: ShapeID, shape: Shape) -> None:
|
||||
self._icon.shapes[shape_id] = deepcopy(shape)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _remove_shape(self, shape_id: ShapeID) -> None:
|
||||
self._icon.shapes.pop(shape_id, None)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _remove_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
|
||||
for shape_id in shapes:
|
||||
self._icon.shapes.pop(shape_id, None)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _restore_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
|
||||
self._icon.shapes.update(deepcopy(shapes))
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _change_shape(self, shape_id: ShapeID, shape: Shape) -> None:
|
||||
self._icon.shapes[shape_id] = deepcopy(shape)
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _move_port(self, port_id: PortID, position: tuple[int, int]) -> None:
|
||||
self._icon.port_positions[port_id] = position
|
||||
self.scene.set_icon(self._icon)
|
||||
self.icon_changed.emit(self.icon())
|
||||
|
||||
def _shape_created(self, shape: Shape) -> None:
|
||||
self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape))
|
||||
|
||||
def _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
|
||||
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
|
||||
|
||||
def _port_moved(self, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
|
||||
self.undo_stack.push(MovePortCommand(self, port_id, old_position, new_position))
|
||||
|
||||
def _delete_selected_shapes(self) -> None:
|
||||
shape_ids = self.scene.selected_shape_ids()
|
||||
shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in shape_ids}
|
||||
if shapes:
|
||||
self.undo_stack.push(DeleteShapesCommand(self, shapes))
|
||||
|
||||
def _show_shape_options(self, shape_id: ShapeID) -> None:
|
||||
old_shape = self._icon.shapes.get(shape_id)
|
||||
if old_shape is None:
|
||||
return
|
||||
dialog = ShapeOptionsDialog(old_shape, self.scene.sceneRect(), self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
new_shape = dialog.shape()
|
||||
if new_shape != old_shape:
|
||||
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
|
||||
|
||||
def _ensure_port_positions(self, ports: dict[PortID, Port]) -> None:
|
||||
bounds = self.scene.sceneRect()
|
||||
left = round(bounds.left())
|
||||
top = round(bounds.top())
|
||||
right = round(bounds.right() - 16)
|
||||
bottom = round(bounds.bottom() - 16)
|
||||
positions: dict[PortID, tuple[int, int]] = {}
|
||||
input_index = 0
|
||||
output_index = 0
|
||||
for port_id, port in ports.items():
|
||||
if port.direction is SignalDirection.INPUT:
|
||||
default = (left, top + input_index * 16)
|
||||
input_index += 1
|
||||
else:
|
||||
default = (right, top + output_index * 16)
|
||||
output_index += 1
|
||||
position = self._icon.port_positions.get(port_id, default)
|
||||
positions[port_id] = (max(left, min(right, position[0])), max(top, min(bottom, position[1])))
|
||||
self._icon.port_positions = positions
|
||||
|
||||
def _start_rectangle_tool(self) -> None:
|
||||
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
|
||||
self.ui.actionAdd_Circle.setChecked(False)
|
||||
self.ui.actionAdd_Text.setChecked(False)
|
||||
self.ui.actionAdd_Line.setChecked(False)
|
||||
self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer))
|
||||
self.ui.actionAdd_Rectangle.setChecked(True)
|
||||
|
||||
def _start_ellipse_tool(self) -> None:
|
||||
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
|
||||
self.ui.actionAdd_Rectangle.setChecked(False)
|
||||
self.ui.actionAdd_Text.setChecked(False)
|
||||
self.ui.actionAdd_Line.setChecked(False)
|
||||
self.scene.set_creation_tool(EllipseCreationTool(self.scene, layer))
|
||||
self.ui.actionAdd_Circle.setChecked(True)
|
||||
|
||||
def _start_text_tool(self) -> None:
|
||||
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
|
||||
self.ui.actionAdd_Rectangle.setChecked(False)
|
||||
self.ui.actionAdd_Circle.setChecked(False)
|
||||
self.ui.actionAdd_Line.setChecked(False)
|
||||
self.scene.set_creation_tool(TextCreationTool(self.scene, layer))
|
||||
self.ui.actionAdd_Text.setChecked(True)
|
||||
|
||||
def _start_line_tool(self) -> None:
|
||||
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
|
||||
self.ui.actionAdd_Rectangle.setChecked(False)
|
||||
self.ui.actionAdd_Circle.setChecked(False)
|
||||
self.ui.actionAdd_Text.setChecked(False)
|
||||
self.scene.set_creation_tool(LineCreationTool(self.scene, layer))
|
||||
self.ui.actionAdd_Line.setChecked(True)
|
||||
|
||||
def _tool_active_changed(self, active: bool) -> None:
|
||||
if not active:
|
||||
self.ui.actionAdd_Rectangle.setChecked(False)
|
||||
self.ui.actionAdd_Circle.setChecked(False)
|
||||
self.ui.actionAdd_Text.setChecked(False)
|
||||
self.ui.actionAdd_Line.setChecked(False)
|
||||
cursor = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor
|
||||
self.ui.graphicsView.viewport().setCursor(cursor)
|
||||
|
||||
def _update_undo_text(self, text: str) -> None:
|
||||
self.ui.actionUndo.setText(f"Undo {text}" if text else "Undo")
|
||||
|
||||
def _update_redo_text(self, text: str) -> None:
|
||||
self.ui.actionRedo.setText(f"Redo {text}" if text else "Redo")
|
||||
519
src/bedit_gui/views/icon_graphics_scene.py
Normal file
519
src/bedit_gui/views/icon_graphics_scene.py
Normal file
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QLineF, QObject, QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPainterPath, QPen
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
|
||||
|
||||
from bedit_core.models import Port, PortID, SignalDirection
|
||||
from bedit_gui.models import Ellipse, Icon, Line, LineType, Rectangle, Shape, ShapeID, Text
|
||||
|
||||
ICON_SCENE_SIZE = 512
|
||||
|
||||
|
||||
class ShapeCreationTool(Protocol):
|
||||
def begin(self, position: QPointF) -> None: ...
|
||||
def update(self, position: QPointF) -> None: ...
|
||||
def finish(self, position: QPointF) -> Shape | None: ...
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
|
||||
class RectangleCreationTool:
|
||||
def __init__(self, scene: QGraphicsScene, layer: int) -> None:
|
||||
self.scene = scene
|
||||
self.layer = layer
|
||||
self.start: QPointF | None = None
|
||||
self.preview: QGraphicsRectItem | QGraphicsEllipseItem | None = None
|
||||
|
||||
def begin(self, position: QPointF) -> None:
|
||||
position = self._bounded(position)
|
||||
self.start = position
|
||||
self.preview = self.scene.addRect(QRectF(position, position), QPen(Qt.PenStyle.DashLine))
|
||||
|
||||
def update(self, position: QPointF) -> None:
|
||||
if self.preview is not None and self.start is not None:
|
||||
position = self._bounded(position)
|
||||
self.preview.setRect(QRectF(self.start, position).normalized())
|
||||
|
||||
def finish(self, position: QPointF) -> Shape | None:
|
||||
if self.start is None:
|
||||
return None
|
||||
position = self._bounded(position)
|
||||
rect = QRectF(self.start, position).normalized()
|
||||
self.cancel()
|
||||
if rect.width() < 1 or rect.height() < 1:
|
||||
return None
|
||||
return self.create_shape(rect)
|
||||
|
||||
def create_shape(self, rect: QRectF) -> Shape:
|
||||
return Rectangle(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height())
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self.preview is not None:
|
||||
self.scene.removeItem(self.preview)
|
||||
self.preview = None
|
||||
self.start = None
|
||||
|
||||
def _bounded(self, position: QPointF) -> QPointF:
|
||||
rect = self.scene.sceneRect()
|
||||
x = max(rect.left(), min(rect.right(), round(position.x())))
|
||||
y = max(rect.top(), min(rect.bottom(), round(position.y())))
|
||||
return QPointF(x, y)
|
||||
|
||||
|
||||
class TextCreationTool(RectangleCreationTool):
|
||||
def create_shape(self, rect: QRectF) -> Shape:
|
||||
return Text(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height(), text="Text")
|
||||
|
||||
|
||||
class EllipseCreationTool(RectangleCreationTool):
|
||||
def begin(self, position: QPointF) -> None:
|
||||
position = self._bounded(position)
|
||||
self.start = position
|
||||
self.preview = self.scene.addEllipse(QRectF(position, position), QPen(Qt.PenStyle.DashLine))
|
||||
|
||||
def create_shape(self, rect: QRectF) -> Shape:
|
||||
return Ellipse(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height())
|
||||
|
||||
|
||||
class LineCreationTool:
|
||||
def __init__(self, scene: QGraphicsScene, layer: int) -> None:
|
||||
self.scene = scene
|
||||
self.layer = layer
|
||||
self.start: QPointF | None = None
|
||||
self.preview: QGraphicsLineItem | None = None
|
||||
|
||||
def begin(self, position: QPointF) -> None:
|
||||
position = self._bounded(position)
|
||||
self.start = position
|
||||
self.preview = self.scene.addLine(QLineF(position, position), QPen(Qt.PenStyle.DashLine))
|
||||
|
||||
def update(self, position: QPointF) -> None:
|
||||
if self.preview is not None and self.start is not None:
|
||||
self.preview.setLine(QLineF(self.start, self._bounded(position)))
|
||||
|
||||
def finish(self, position: QPointF) -> Shape | None:
|
||||
if self.start is None:
|
||||
return None
|
||||
start = self.start
|
||||
end = self._bounded(position)
|
||||
self.cancel()
|
||||
if start == end:
|
||||
return None
|
||||
return Line(layer=self.layer, pos=(round(start.x()), round(start.y())), end=(round(end.x()), round(end.y())))
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self.preview is not None:
|
||||
self.scene.removeItem(self.preview)
|
||||
self.preview = None
|
||||
self.start = None
|
||||
|
||||
def _bounded(self, position: QPointF) -> QPointF:
|
||||
rect = self.scene.sceneRect()
|
||||
x = max(rect.left(), min(rect.right(), round(position.x())))
|
||||
y = max(rect.top(), min(rect.bottom(), round(position.y())))
|
||||
return QPointF(x, y)
|
||||
|
||||
|
||||
class ShapeGraphicsItem(QGraphicsPathItem):
|
||||
handle_size = 8.0
|
||||
handle_hit_size = 16.0
|
||||
|
||||
def __init__(self, shape_id: ShapeID, shape: Shape, scene: IconGraphicsScene) -> None:
|
||||
super().__init__()
|
||||
self.shape_id = shape_id
|
||||
self.shape_model = deepcopy(shape)
|
||||
self.icon_scene = scene
|
||||
self._original_shape: Shape | None = None
|
||||
self._resize_handle: str | None = None
|
||||
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def resize_handle_rect(self) -> QRectF:
|
||||
size = self.handle_size
|
||||
corner = self.path().boundingRect().bottomRight()
|
||||
return QRectF(corner.x() - size / 2, corner.y() - size / 2, size, size)
|
||||
|
||||
def resize_handles(self) -> dict[str, QRectF]:
|
||||
return {"size": self.resize_handle_rect()}
|
||||
|
||||
def resize_handle_hit_rects(self) -> dict[str, QRectF]:
|
||||
size = self.handle_hit_size
|
||||
return {name: QRectF(rect.center().x() - size / 2, rect.center().y() - size / 2, size, size) for name, rect in self.resize_handles().items()}
|
||||
|
||||
def boundingRect(self) -> QRectF:
|
||||
margin = self.handle_hit_size / 2
|
||||
return super().boundingRect().adjusted(-margin, -margin, margin, margin)
|
||||
|
||||
def shape(self) -> QPainterPath:
|
||||
path = super().shape()
|
||||
if self.isSelected():
|
||||
for rect in self.resize_handle_hit_rects().values():
|
||||
path.addRect(rect)
|
||||
return path
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
self._original_shape = self.current_shape()
|
||||
self._resize_handle = next((name for name, rect in self.resize_handle_hit_rects().items() if self.isSelected() and rect.contains(event.pos())), None)
|
||||
if self._resize_handle is not None:
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self._resize_handle is not None:
|
||||
self.resize_to(event.scenePos(), self._resize_handle)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self._resize_handle is not None:
|
||||
self.resize_to(event.scenePos(), self._resize_handle)
|
||||
self._resize_handle = None
|
||||
event.accept()
|
||||
else:
|
||||
super().mouseReleaseEvent(event)
|
||||
current = self.current_shape()
|
||||
if self._original_shape is not None and current != self._original_shape:
|
||||
self.icon_scene.shape_changed.emit(self.shape_id, self._original_shape, current)
|
||||
self._original_shape = None
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
|
||||
if not self.isSelected():
|
||||
self.icon_scene.clearSelection()
|
||||
self.setSelected(True)
|
||||
menu = QMenu()
|
||||
options = menu.addAction("Shape Options")
|
||||
if menu.exec(event.screenPos()) is options:
|
||||
self.icon_scene.shape_options_requested.emit(self.shape_id)
|
||||
event.accept()
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
|
||||
position = value
|
||||
if isinstance(position, QPointF):
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
shape_rect = self.path().boundingRect()
|
||||
x = max(bounds.left() - shape_rect.left(), min(bounds.right() - shape_rect.right(), round(position.x())))
|
||||
y = max(bounds.top() - shape_rect.top(), min(bounds.bottom() - shape_rect.bottom(), round(position.y())))
|
||||
return QPointF(x, y)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
|
||||
super().paint(painter, option, widget)
|
||||
if self.isSelected():
|
||||
painter.setPen(QPen(QColor("#ffffff")))
|
||||
painter.setBrush(QBrush(QColor("#2675bf")))
|
||||
for rect in self.resize_handles().values():
|
||||
painter.drawRect(rect)
|
||||
|
||||
def current_shape(self) -> Shape:
|
||||
raise NotImplementedError
|
||||
|
||||
def resize_to(self, position: QPointF, handle: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RectangleGraphicsItem(ShapeGraphicsItem):
|
||||
def __init__(self, shape_id: ShapeID, shape: Rectangle, scene: IconGraphicsScene) -> None:
|
||||
super().__init__(shape_id, shape, scene)
|
||||
self.rectangle = deepcopy(shape)
|
||||
self.setPos(shape.pos[0], shape.pos[1])
|
||||
self._set_size(shape.width, shape.height)
|
||||
self.setPen(scene._pen(shape))
|
||||
self.setBrush(QBrush(scene._color(shape.fill_color)))
|
||||
self.setZValue(shape.layer)
|
||||
|
||||
def current_shape(self) -> Rectangle:
|
||||
shape = deepcopy(self.rectangle)
|
||||
shape.pos = (round(self.pos().x()), round(self.pos().y()))
|
||||
rect = self.path().boundingRect()
|
||||
shape.width = rect.width()
|
||||
shape.height = rect.height()
|
||||
return shape
|
||||
|
||||
def resize_to(self, position: QPointF, _handle: str) -> None:
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
|
||||
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
|
||||
self._set_size(width, height)
|
||||
|
||||
def _set_size(self, width: float, height: float) -> None:
|
||||
self.prepareGeometryChange()
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(QRectF(0, 0, width, height), self.rectangle.corner_radius, self.rectangle.corner_radius)
|
||||
self.setPath(path)
|
||||
|
||||
|
||||
class EllipseGraphicsItem(ShapeGraphicsItem):
|
||||
def __init__(self, shape_id: ShapeID, shape: Ellipse, scene: IconGraphicsScene) -> None:
|
||||
super().__init__(shape_id, shape, scene)
|
||||
self.ellipse = deepcopy(shape)
|
||||
self.setPos(shape.pos[0], shape.pos[1])
|
||||
self._set_size(shape.width, shape.height)
|
||||
self.setPen(scene._pen(shape))
|
||||
self.setBrush(QBrush(scene._color(shape.fill_color)))
|
||||
self.setZValue(shape.layer)
|
||||
|
||||
def current_shape(self) -> Ellipse:
|
||||
shape = deepcopy(self.ellipse)
|
||||
shape.pos = (round(self.pos().x()), round(self.pos().y()))
|
||||
rect = self.path().boundingRect()
|
||||
shape.width = rect.width()
|
||||
shape.height = rect.height()
|
||||
return shape
|
||||
|
||||
def resize_to(self, position: QPointF, _handle: str) -> None:
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
|
||||
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
|
||||
self._set_size(width, height)
|
||||
|
||||
def _set_size(self, width: float, height: float) -> None:
|
||||
self.prepareGeometryChange()
|
||||
path = QPainterPath()
|
||||
path.addEllipse(QRectF(0, 0, width, height))
|
||||
self.setPath(path)
|
||||
|
||||
|
||||
class TextGraphicsItem(ShapeGraphicsItem):
|
||||
def __init__(self, shape_id: ShapeID, shape: Text, scene: IconGraphicsScene) -> None:
|
||||
super().__init__(shape_id, shape, scene)
|
||||
self.text = deepcopy(shape)
|
||||
self.setPos(shape.pos[0], shape.pos[1])
|
||||
self._set_size(shape.width, shape.height)
|
||||
self.setPen(QPen(Qt.PenStyle.NoPen))
|
||||
self.setBrush(QBrush(QColor(0, 0, 0, 0)))
|
||||
self.setZValue(shape.layer)
|
||||
|
||||
def current_shape(self) -> Text:
|
||||
shape = deepcopy(self.text)
|
||||
shape.pos = (round(self.pos().x()), round(self.pos().y()))
|
||||
rect = self.path().boundingRect()
|
||||
shape.width = rect.width()
|
||||
shape.height = rect.height()
|
||||
return shape
|
||||
|
||||
def resize_to(self, position: QPointF, _handle: str) -> None:
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
|
||||
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
|
||||
self._set_size(width, height)
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
|
||||
super().paint(painter, option, widget)
|
||||
font = QFont()
|
||||
font.setPixelSize(max(1, round(self.text.size)))
|
||||
font.setBold(self.text.bold)
|
||||
font.setItalic(self.text.italic)
|
||||
painter.setFont(font)
|
||||
painter.setPen(self.icon_scene._color(self.text.color))
|
||||
painter.setClipRect(self.path().boundingRect())
|
||||
painter.drawText(self.path().boundingRect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.text.text)
|
||||
|
||||
def _set_size(self, width: float, height: float) -> None:
|
||||
self.prepareGeometryChange()
|
||||
path = QPainterPath()
|
||||
path.addRect(QRectF(0, 0, width, height))
|
||||
self.setPath(path)
|
||||
|
||||
|
||||
class LineGraphicsItem(ShapeGraphicsItem):
|
||||
def __init__(self, shape_id: ShapeID, shape: Line, scene: IconGraphicsScene) -> None:
|
||||
super().__init__(shape_id, shape, scene)
|
||||
self.line = deepcopy(shape)
|
||||
self._set_points(QPointF(shape.pos[0], shape.pos[1]), QPointF(shape.end[0], shape.end[1]))
|
||||
self.setPen(scene._line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||
self.setZValue(shape.layer)
|
||||
|
||||
def resize_handles(self) -> dict[str, QRectF]:
|
||||
size = self.handle_size
|
||||
start = self.path().pointAtPercent(0)
|
||||
end = self.path().pointAtPercent(1)
|
||||
return {"start": QRectF(start.x() - size / 2, start.y() - size / 2, size, size), "end": QRectF(end.x() - size / 2, end.y() - size / 2, size, size)}
|
||||
|
||||
def current_shape(self) -> Line:
|
||||
shape = deepcopy(self.line)
|
||||
start = self.mapToScene(self.path().pointAtPercent(0))
|
||||
end = self.mapToScene(self.path().pointAtPercent(1))
|
||||
shape.pos = (round(start.x()), round(start.y()))
|
||||
shape.end = (round(end.x()), round(end.y()))
|
||||
return shape
|
||||
|
||||
def resize_to(self, position: QPointF, handle: str) -> None:
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
position = QPointF(max(bounds.left(), min(bounds.right(), round(position.x()))), max(bounds.top(), min(bounds.bottom(), round(position.y()))))
|
||||
start = self.mapToScene(self.path().pointAtPercent(0))
|
||||
end = self.mapToScene(self.path().pointAtPercent(1))
|
||||
start = position if handle == "start" else start
|
||||
end = position if handle == "end" else end
|
||||
self.setPos(0, 0)
|
||||
self._set_points(start, end)
|
||||
|
||||
def _set_points(self, start: QPointF, end: QPointF) -> None:
|
||||
self.prepareGeometryChange()
|
||||
path = QPainterPath(start)
|
||||
path.lineTo(end)
|
||||
self.setPath(path)
|
||||
|
||||
|
||||
class PortGraphicsItem(QGraphicsRectItem):
|
||||
size = 16.0
|
||||
|
||||
def __init__(self, port_id: PortID, port: Port, position: tuple[int, int], scene: IconGraphicsScene) -> None:
|
||||
super().__init__(0, 0, self.size, self.size)
|
||||
self.port_id = port_id
|
||||
self.icon_scene = scene
|
||||
self._original_position: tuple[int, int] | None = None
|
||||
self.setPos(position[0], position[1])
|
||||
self.setPen(QPen(QColor("#000000")))
|
||||
self.setBrush(QBrush(QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")))
|
||||
self.setZValue(1000000)
|
||||
self.setToolTip(port.name)
|
||||
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
self._original_position = self.position()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
super().mouseReleaseEvent(event)
|
||||
position = self.position()
|
||||
if self._original_position is not None and position != self._original_position:
|
||||
self.icon_scene.port_moved.emit(self.port_id, self._original_position, position)
|
||||
self._original_position = None
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
|
||||
position = value
|
||||
if isinstance(position, QPointF):
|
||||
bounds = self.icon_scene.sceneRect()
|
||||
x = max(bounds.left(), min(bounds.right() - self.size, round(position.x())))
|
||||
y = max(bounds.top(), min(bounds.bottom() - self.size, round(position.y())))
|
||||
return QPointF(x, y)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def position(self) -> tuple[int, int]:
|
||||
return (round(self.pos().x()), round(self.pos().y()))
|
||||
|
||||
|
||||
class IconGraphicsScene(QGraphicsScene):
|
||||
port_moved = Signal(object, object, object)
|
||||
shape_created = Signal(object)
|
||||
shape_changed = Signal(object, object, object)
|
||||
shape_options_requested = Signal(object)
|
||||
tool_active_changed = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._tool: ShapeCreationTool | None = None
|
||||
self._ports: dict[PortID, Port] = {}
|
||||
self.setSceneRect(-ICON_SCENE_SIZE / 2, -ICON_SCENE_SIZE / 2, ICON_SCENE_SIZE, ICON_SCENE_SIZE)
|
||||
|
||||
def set_ports(self, ports: dict[PortID, Port]) -> None:
|
||||
self._ports = deepcopy(ports)
|
||||
|
||||
def set_icon(self, icon: Icon) -> None:
|
||||
self.cancel_creation_tool()
|
||||
self.clear()
|
||||
for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer):
|
||||
self._add_shape_item(shape_id, shape)
|
||||
for port_id, port in self._ports.items():
|
||||
position = icon.port_positions.get(port_id)
|
||||
if position is not None:
|
||||
self.addItem(PortGraphicsItem(port_id, port, position, self))
|
||||
|
||||
def set_creation_tool(self, tool: ShapeCreationTool) -> None:
|
||||
self.cancel_creation_tool()
|
||||
self._tool = tool
|
||||
self.tool_active_changed.emit(True)
|
||||
|
||||
def cancel_creation_tool(self) -> None:
|
||||
if self._tool is None:
|
||||
return
|
||||
self._tool.cancel()
|
||||
self._tool = None
|
||||
self.tool_active_changed.emit(False)
|
||||
|
||||
def has_creation_tool(self) -> bool:
|
||||
return self._tool is not None
|
||||
|
||||
def selected_shape_ids(self) -> list[ShapeID]:
|
||||
return [item.shape_id for item in self.selectedItems() if isinstance(item, ShapeGraphicsItem)]
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
|
||||
super().drawBackground(painter, rect)
|
||||
rect = rect.intersected(self.sceneRect())
|
||||
if rect.isEmpty():
|
||||
return
|
||||
pen = QPen(QColor("#d0d0d0"))
|
||||
pen.setCosmetic(True)
|
||||
painter.setPen(pen)
|
||||
|
||||
first_x = math.floor(rect.left() / 8) * 8
|
||||
last_x = math.ceil(rect.right() / 8) * 8
|
||||
first_y = math.floor(rect.top() / 8) * 8
|
||||
last_y = math.ceil(rect.bottom() / 8) * 8
|
||||
|
||||
for x in range(first_x, last_x + 1, 8):
|
||||
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
|
||||
|
||||
for y in range(first_y, last_y + 1, 8):
|
||||
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self._tool is not None and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._tool.begin(event.scenePos())
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self._tool is not None and event.buttons() & Qt.MouseButton.LeftButton:
|
||||
self._tool.update(event.scenePos())
|
||||
event.accept()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self._tool is not None and event.button() == Qt.MouseButton.LeftButton:
|
||||
tool = self._tool
|
||||
shape = tool.finish(event.scenePos())
|
||||
self._tool = None
|
||||
self.tool_active_changed.emit(False)
|
||||
if shape is not None:
|
||||
self.shape_created.emit(shape)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def _add_shape_item(self, shape_id: ShapeID, shape: Shape) -> None:
|
||||
if isinstance(shape, Rectangle):
|
||||
self.addItem(RectangleGraphicsItem(shape_id, shape, self))
|
||||
elif isinstance(shape, Ellipse):
|
||||
self.addItem(EllipseGraphicsItem(shape_id, shape, self))
|
||||
elif isinstance(shape, Text):
|
||||
self.addItem(TextGraphicsItem(shape_id, shape, self))
|
||||
elif isinstance(shape, Line):
|
||||
self.addItem(LineGraphicsItem(shape_id, shape, self))
|
||||
|
||||
@staticmethod
|
||||
def _pen(shape: Rectangle | Ellipse) -> QPen:
|
||||
return IconGraphicsScene._line_pen(shape.line_type, shape.line_thickness, shape.line_color)
|
||||
|
||||
@staticmethod
|
||||
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
|
||||
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
|
||||
if line_type is LineType.NONE:
|
||||
return QPen(Qt.PenStyle.NoPen)
|
||||
return QPen(IconGraphicsScene._color(color), thickness, styles[line_type])
|
||||
|
||||
@staticmethod
|
||||
def _color(value: str) -> QColor:
|
||||
color = value.removeprefix("#")
|
||||
if len(color) == 8:
|
||||
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
|
||||
return QColor(value)
|
||||
@@ -1,16 +1,27 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QMainWindow, QTabWidget
|
||||
from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout
|
||||
|
||||
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
|
||||
from bedit_gui.views.equation_editor_widget import EquationEditorWidget
|
||||
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, snap_to_grid_size: int = 4) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.ui = Ui_MainWindow()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
central_layout = QVBoxLayout(self.ui.centralwidget)
|
||||
central_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
|
||||
self.equation_editor.hide()
|
||||
central_layout.addWidget(self.equation_editor)
|
||||
self.graph_editor = GraphEditorWidget(self.ui.centralwidget, snap_to_grid_size)
|
||||
self.graph_editor.hide()
|
||||
central_layout.addWidget(self.graph_editor)
|
||||
|
||||
self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North)
|
||||
self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)
|
||||
self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from bedit_core.models import Component, ComponentID, GraphImplementation
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
@@ -12,6 +13,7 @@ from bedit_core.models import Document as CoreDocument
|
||||
class DocumentTreeNode:
|
||||
name: str
|
||||
value: object
|
||||
component_id: ComponentID | None
|
||||
parent: DocumentTreeNode | None
|
||||
children: list[DocumentTreeNode]
|
||||
|
||||
@@ -20,26 +22,45 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
rename_document_requested = Signal(str)
|
||||
rename_component_requested = Signal(Component, str)
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, editable: bool = True) -> None:
|
||||
super().__init__()
|
||||
self._document: CoreDocument | None = None
|
||||
self._root = DocumentTreeNode("Document", None, None, [])
|
||||
self._editable = editable
|
||||
self._root = DocumentTreeNode("Document", None, None, None, [])
|
||||
self._component_icons: dict[ComponentID, QIcon] = {}
|
||||
self._component_nodes: dict[ComponentID, DocumentTreeNode] = {}
|
||||
|
||||
def set_document(self, document: CoreDocument) -> None:
|
||||
self.set_documents([document])
|
||||
|
||||
def set_documents(self, documents: list[CoreDocument]) -> None:
|
||||
self.beginResetModel()
|
||||
self._document = document
|
||||
self._root = self._build_tree(document)
|
||||
self._component_icons = {}
|
||||
self._component_nodes = {}
|
||||
self._root = self._build_tree(documents)
|
||||
self.endResetModel()
|
||||
|
||||
def set_component_icon(self, component_id: ComponentID, icon: QIcon) -> None:
|
||||
node = self._component_nodes.get(component_id)
|
||||
if node is None or node.parent is None:
|
||||
return
|
||||
self._component_icons[component_id] = icon
|
||||
row = node.parent.children.index(node)
|
||||
index = self.createIndex(row, 1, node)
|
||||
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DecorationRole])
|
||||
|
||||
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
||||
if parent is not None and parent.isValid() and parent.column() != 0:
|
||||
return 0
|
||||
return len(self._node(parent).children)
|
||||
|
||||
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
||||
return 1
|
||||
return 2
|
||||
|
||||
def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex:
|
||||
if parent is not None and parent.isValid() and parent.column() != 0:
|
||||
return QModelIndex()
|
||||
parent_node = self._node(parent)
|
||||
if column != 0 or row < 0 or row >= len(parent_node.children):
|
||||
if column not in (0,1) or row < 0 or row >= len(parent_node.children):
|
||||
return QModelIndex()
|
||||
return self.createIndex(row, column, parent_node.children[row])
|
||||
|
||||
@@ -69,13 +90,19 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
if not isinstance(node, DocumentTreeNode):
|
||||
return None
|
||||
|
||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||
return node.name
|
||||
if index.column() == 0:
|
||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||
return node.name
|
||||
elif index.column() == 1:
|
||||
if role == Qt.ItemDataRole.DecorationRole:
|
||||
return self._component_icons.get(node.component_id)
|
||||
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||
return Qt.AlignmentFlag.AlignCenter
|
||||
|
||||
return None
|
||||
|
||||
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool:
|
||||
if role != Qt.ItemDataRole.EditRole or not index.isValid():
|
||||
if role != Qt.ItemDataRole.EditRole or not index.isValid() or index.column() != 0:
|
||||
return False
|
||||
|
||||
node = index.internalPointer()
|
||||
@@ -106,6 +133,12 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
node = index.internalPointer()
|
||||
return node.value if isinstance(node, DocumentTreeNode) else None
|
||||
|
||||
def component_index(self, component_id: ComponentID) -> QModelIndex:
|
||||
node = self._component_nodes.get(component_id)
|
||||
if node is None or node.parent is None:
|
||||
return QModelIndex()
|
||||
return self.createIndex(node.parent.children.index(node), 0, node)
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
flags = super().flags(index)
|
||||
|
||||
@@ -115,31 +148,32 @@ class DocumentTreeModel(QAbstractItemModel):
|
||||
node = index.internalPointer()
|
||||
|
||||
# Make the document root node editable
|
||||
if isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
||||
if self._editable and index.column() == 0 and isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
||||
flags |= Qt.ItemFlag.ItemIsEditable
|
||||
|
||||
return flags
|
||||
|
||||
def _build_tree(self, document: CoreDocument) -> DocumentTreeNode:
|
||||
def _build_tree(self, documents: list[CoreDocument]) -> DocumentTreeNode:
|
||||
# QT's invisible root
|
||||
root = DocumentTreeNode(
|
||||
name="",
|
||||
value=None,
|
||||
component_id=None,
|
||||
parent=None,
|
||||
children=[],
|
||||
)
|
||||
# Add itself as a child so the document root is visible in the tree
|
||||
document_root = DocumentTreeNode(name=document.name, value=document, parent=root, children=[])
|
||||
root.children.append(document_root)
|
||||
|
||||
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
|
||||
for component in components.values():
|
||||
component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[])
|
||||
root.children.append(component_node)
|
||||
def _list_children(parent: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
|
||||
for component_id, component in sorted(components.items(), key=lambda item: (item[1].name.casefold(), item[1].name, str(item[0]))):
|
||||
component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=parent, children=[])
|
||||
parent.children.append(component_node)
|
||||
self._component_nodes[component_id] = component_node
|
||||
|
||||
if isinstance(component.implementation, GraphImplementation):
|
||||
_list_children(component_node, component.implementation.graph.components)
|
||||
|
||||
_list_children(document_root, document.root)
|
||||
for document in documents:
|
||||
document_root = DocumentTreeNode(name=document.name, value=document, component_id=None, parent=root, children=[])
|
||||
root.children.append(document_root)
|
||||
_list_children(document_root, document.root)
|
||||
|
||||
return root
|
||||
|
||||
55
src/bedit_gui/views/models/library_tree_model.py
Normal file
55
src/bedit_gui/views/models/library_tree_model.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtCore import QMimeData, QModelIndex, Qt
|
||||
|
||||
from bedit_core.models import Component
|
||||
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
|
||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||
|
||||
PayloadFactory = Callable[[list[Component]], dict]
|
||||
|
||||
|
||||
class LibraryTreeModel(DocumentTreeModel):
|
||||
def __init__(self, payload_factory: PayloadFactory) -> None:
|
||||
super().__init__(editable=False)
|
||||
self._payload_factory = payload_factory
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
flags = super().flags(index)
|
||||
if isinstance(self.value(index), Component):
|
||||
flags |= Qt.ItemFlag.ItemIsDragEnabled
|
||||
return flags
|
||||
|
||||
def mimeTypes(self) -> list[str]:
|
||||
return [COMPONENTS_MIME]
|
||||
|
||||
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData:
|
||||
rows = [index for index in indexes if index.column() == 0 and isinstance(self.value(index), Component)]
|
||||
selected = {id(self.value(index)) for index in rows}
|
||||
components = []
|
||||
added = set()
|
||||
for index in rows:
|
||||
parent = index.parent()
|
||||
if any(id(self.value(parent_index)) in selected for parent_index in self._parents(parent)):
|
||||
continue
|
||||
component = self.value(index)
|
||||
if isinstance(component, Component) and id(component) not in added:
|
||||
components.append(component)
|
||||
added.add(id(component))
|
||||
mime = QMimeData()
|
||||
if components:
|
||||
mime.setData(COMPONENTS_MIME, json.dumps(self._payload_factory(components)).encode("utf-8"))
|
||||
mime.setText("\n".join(component.name for component in components))
|
||||
return mime
|
||||
|
||||
def supportedDragActions(self) -> Qt.DropAction:
|
||||
return Qt.DropAction.CopyAction
|
||||
|
||||
@staticmethod
|
||||
def _parents(index: QModelIndex):
|
||||
while index.isValid():
|
||||
yield index
|
||||
index = index.parent()
|
||||
@@ -7,6 +7,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide6.QtWidgets import QButtonGroup, QLayout, QWidget
|
||||
|
||||
from bedit_core.models import BondPort, Port, PortCausality, PortID, SignalDirection, SignalPort, ValueType
|
||||
from bedit_gui.models import PortMetadata
|
||||
from bedit_gui.ui.generated.ui_port_editor_widget import Ui_PortEditor
|
||||
|
||||
|
||||
@@ -21,6 +22,7 @@ class PortEditorWidget(QWidget):
|
||||
self.ui = Ui_PortEditor()
|
||||
self.ui.setupUi(self)
|
||||
self._ports: dict[PortID, Port] = {}
|
||||
self._port_metadata: dict[PortID, PortMetadata] = {}
|
||||
self._port_ids: list[PortID] = []
|
||||
self._loading = False
|
||||
|
||||
@@ -54,6 +56,7 @@ class PortEditorWidget(QWidget):
|
||||
self.ui.widthSize.valueChanged.connect(self._form_changed)
|
||||
self.ui.heightSize.valueChanged.connect(self._form_changed)
|
||||
self.ui.multiplicityCheckBox.toggled.connect(self._form_changed)
|
||||
self.ui.connectionAnnotationEdit.textChanged.connect(self._form_changed)
|
||||
self.ui.signalTypeComboBox.currentIndexChanged.connect(self._form_changed)
|
||||
self.ui.domainComboBox.currentTextChanged.connect(self._form_changed)
|
||||
self.ui.causalityComboBox.currentIndexChanged.connect(self._form_changed)
|
||||
@@ -62,14 +65,18 @@ class PortEditorWidget(QWidget):
|
||||
self._set_editor_enabled(False)
|
||||
self._update_option_visibility()
|
||||
|
||||
def set_ports(self, ports: dict[PortID, Port]) -> None:
|
||||
def set_ports(self, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
|
||||
self._ports = deepcopy(ports)
|
||||
self._port_metadata = deepcopy(port_metadata or {})
|
||||
self._port_ids = list(self._ports)
|
||||
self._rebuild_list()
|
||||
|
||||
def ports(self) -> dict[PortID, Port]:
|
||||
return deepcopy(self._ports)
|
||||
|
||||
def port_metadata(self) -> dict[PortID, PortMetadata]:
|
||||
return deepcopy(self._port_metadata)
|
||||
|
||||
def _rebuild_list(self, selected_id: PortID | None = None) -> None:
|
||||
self._list_model.clear()
|
||||
for port_id in self._port_ids:
|
||||
@@ -90,7 +97,7 @@ class PortEditorWidget(QWidget):
|
||||
self.ui.removePort.setEnabled(port_id is not None)
|
||||
self._set_editor_enabled(port_id is not None)
|
||||
if port_id is not None:
|
||||
self._load_port(self._ports[port_id])
|
||||
self._load_port(self._ports[port_id], self._port_metadata.get(port_id, PortMetadata()))
|
||||
|
||||
def _selected_port_id(self) -> PortID | None:
|
||||
index = self.ui.portList.currentIndex()
|
||||
@@ -98,7 +105,7 @@ class PortEditorWidget(QWidget):
|
||||
return None
|
||||
return self._port_ids[index.row()]
|
||||
|
||||
def _load_port(self, port: Port) -> None:
|
||||
def _load_port(self, port: Port, metadata: PortMetadata) -> None:
|
||||
self._loading = True
|
||||
self.ui.nameEdit.setText(port.name)
|
||||
self.ui.inputOrientation.setChecked(port.direction is SignalDirection.INPUT)
|
||||
@@ -106,6 +113,7 @@ class PortEditorWidget(QWidget):
|
||||
self.ui.widthSize.setValue(port.matrix_size[0])
|
||||
self.ui.heightSize.setValue(port.matrix_size[1])
|
||||
self.ui.multiplicityCheckBox.setChecked(port.multiplicity)
|
||||
self.ui.connectionAnnotationEdit.setText(metadata.connection_annotation or "")
|
||||
self.ui.descriptionEdit.setPlainText(port.description or "")
|
||||
|
||||
if isinstance(port, SignalPort):
|
||||
@@ -135,6 +143,12 @@ class PortEditorWidget(QWidget):
|
||||
|
||||
old_port = self._ports[port_id]
|
||||
self._ports[port_id] = self._port_from_form(old_port)
|
||||
annotation = (self.ui.connectionAnnotationEdit.text().strip() or None) if self.ui.multiplicityCheckBox.isChecked() else None
|
||||
metadata = PortMetadata(connection_annotation=annotation)
|
||||
if metadata == PortMetadata():
|
||||
self._port_metadata.pop(port_id, None)
|
||||
else:
|
||||
self._port_metadata[port_id] = metadata
|
||||
self._list_model.item(self._port_ids.index(port_id)).setText(
|
||||
self._ports[port_id].name
|
||||
)
|
||||
@@ -188,6 +202,7 @@ class PortEditorWidget(QWidget):
|
||||
return
|
||||
row = self._port_ids.index(port_id)
|
||||
del self._ports[port_id]
|
||||
self._port_metadata.pop(port_id, None)
|
||||
self._port_ids.remove(port_id)
|
||||
selected = (
|
||||
self._port_ids[min(row, len(self._port_ids) - 1)]
|
||||
@@ -201,6 +216,9 @@ class PortEditorWidget(QWidget):
|
||||
signal = self.ui.typeSignal.isChecked()
|
||||
self._set_layout_visible(self.ui.signalOptions, signal)
|
||||
self._set_layout_visible(self.ui.bondOptions, not signal)
|
||||
annotation_visible = self.ui.multiplicityCheckBox.isChecked()
|
||||
self.ui.connectionAnnotationLabel.setVisible(annotation_visible)
|
||||
self.ui.connectionAnnotationEdit.setVisible(annotation_visible)
|
||||
|
||||
@staticmethod
|
||||
def _set_layout_visible(layout: QLayout, visible: bool) -> None:
|
||||
|
||||
166
src/bedit_gui/views/shape_options_dialog.py
Normal file
166
src/bedit_gui/views/shape_options_dialog.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import QRectF
|
||||
from PySide6.QtWidgets import QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QLabel, QLineEdit, QSpinBox, QVBoxLayout, QWidget
|
||||
|
||||
from bedit_gui.models import Ellipse, Line, LineType, Rectangle, Shape, Text
|
||||
from bedit_gui.views.color_button import ColorButton
|
||||
|
||||
|
||||
class ShapeOptionsDialog(QDialog):
|
||||
def __init__(self, shape: Shape, scene_rect: QRectF, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._shape = deepcopy(shape)
|
||||
self._scene_rect = scene_rect
|
||||
self.setWindowTitle("Shape Options")
|
||||
|
||||
self.form = QFormLayout()
|
||||
self.type_label = QLabel(shape.type or "")
|
||||
self.layer = QSpinBox()
|
||||
self.layer.setRange(-1000000, 1000000)
|
||||
self.layer.setValue(shape.layer)
|
||||
self.x = QSpinBox()
|
||||
self.x.setRange(round(scene_rect.left()), round(scene_rect.right() - 1))
|
||||
self.x.setValue(shape.pos[0])
|
||||
self.y = QSpinBox()
|
||||
self.y.setRange(round(scene_rect.top()), round(scene_rect.bottom() - 1))
|
||||
self.y.setValue(shape.pos[1])
|
||||
self.form.addRow("Type", self.type_label)
|
||||
self.form.addRow("Layer", self.layer)
|
||||
self.form.addRow("X", self.x)
|
||||
self.form.addRow("Y", self.y)
|
||||
|
||||
if isinstance(shape, Rectangle):
|
||||
self._add_rectangle_fields(shape)
|
||||
elif isinstance(shape, Ellipse):
|
||||
self._add_ellipse_fields(shape)
|
||||
elif isinstance(shape, Text):
|
||||
self._add_text_fields(shape)
|
||||
elif isinstance(shape, Line):
|
||||
self._add_line_fields(shape)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(self.form)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def shape(self) -> Shape:
|
||||
if isinstance(self._shape, Rectangle):
|
||||
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
|
||||
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
|
||||
return Rectangle(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), corner_radius=self.corner_radius.value(), line_color=self.line_color.color(), fill_color=self.fill_color.color())
|
||||
if isinstance(self._shape, Ellipse):
|
||||
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
|
||||
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
|
||||
return Ellipse(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), line_color=self.line_color.color(), fill_color=self.fill_color.color())
|
||||
if isinstance(self._shape, Text):
|
||||
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
|
||||
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
|
||||
return Text(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, color=self.color.color(), bold=self.bold.isChecked(), italic=self.italic.isChecked(), size=self.size.value(), text=self.text.text())
|
||||
if isinstance(self._shape, Line):
|
||||
return Line(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), end=(self.end_x.value(), self.end_y.value()), line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), line_color=self.line_color.color())
|
||||
shape = deepcopy(self._shape)
|
||||
shape.layer = self.layer.value()
|
||||
shape.pos = (self.x.value(), self.y.value())
|
||||
return shape
|
||||
|
||||
def _add_rectangle_fields(self, shape: Rectangle) -> None:
|
||||
self.width = QSpinBox()
|
||||
self.width.setRange(1, round(self._scene_rect.width()))
|
||||
self.width.setValue(round(shape.width))
|
||||
self.height = QSpinBox()
|
||||
self.height.setRange(1, round(self._scene_rect.height()))
|
||||
self.height.setValue(round(shape.height))
|
||||
self.line_type = QComboBox()
|
||||
for line_type in LineType:
|
||||
self.line_type.addItem(line_type.value.replace("_", " ").title(), line_type)
|
||||
self.line_type.setCurrentIndex(self.line_type.findData(shape.line_type))
|
||||
self.line_thickness = QDoubleSpinBox()
|
||||
self.line_thickness.setRange(0, 1000)
|
||||
self.line_thickness.setValue(shape.line_thickness)
|
||||
self.corner_radius = QDoubleSpinBox()
|
||||
self.corner_radius.setRange(0, max(self._scene_rect.width(), self._scene_rect.height()))
|
||||
self.corner_radius.setValue(shape.corner_radius)
|
||||
self.line_color = ColorButton(shape.line_color)
|
||||
self.fill_color = ColorButton(shape.fill_color)
|
||||
self.form.addRow("Width", self.width)
|
||||
self.form.addRow("Height", self.height)
|
||||
self.form.addRow("Line type", self.line_type)
|
||||
self.form.addRow("Line thickness", self.line_thickness)
|
||||
self.form.addRow("Corner radius", self.corner_radius)
|
||||
self.form.addRow("Line color", self.line_color)
|
||||
self.form.addRow("Fill color", self.fill_color)
|
||||
|
||||
def _add_ellipse_fields(self, shape: Ellipse) -> None:
|
||||
self.width = QSpinBox()
|
||||
self.width.setRange(1, round(self._scene_rect.width()))
|
||||
self.width.setValue(round(shape.width))
|
||||
self.height = QSpinBox()
|
||||
self.height.setRange(1, round(self._scene_rect.height()))
|
||||
self.height.setValue(round(shape.height))
|
||||
self.line_type = QComboBox()
|
||||
for line_type in LineType:
|
||||
self.line_type.addItem(line_type.value.replace("_", " ").title(), line_type)
|
||||
self.line_type.setCurrentIndex(self.line_type.findData(shape.line_type))
|
||||
self.line_thickness = QDoubleSpinBox()
|
||||
self.line_thickness.setRange(0, 1000)
|
||||
self.line_thickness.setValue(shape.line_thickness)
|
||||
self.line_color = ColorButton(shape.line_color)
|
||||
self.fill_color = ColorButton(shape.fill_color)
|
||||
self.form.addRow("Width", self.width)
|
||||
self.form.addRow("Height", self.height)
|
||||
self.form.addRow("Line type", self.line_type)
|
||||
self.form.addRow("Line thickness", self.line_thickness)
|
||||
self.form.addRow("Line color", self.line_color)
|
||||
self.form.addRow("Fill color", self.fill_color)
|
||||
|
||||
def _add_text_fields(self, shape: Text) -> None:
|
||||
self.width = QSpinBox()
|
||||
self.width.setRange(1, round(self._scene_rect.width()))
|
||||
self.width.setValue(round(shape.width))
|
||||
self.height = QSpinBox()
|
||||
self.height.setRange(1, round(self._scene_rect.height()))
|
||||
self.height.setValue(round(shape.height))
|
||||
self.color = ColorButton(shape.color)
|
||||
self.bold = QCheckBox()
|
||||
self.bold.setChecked(shape.bold)
|
||||
self.italic = QCheckBox()
|
||||
self.italic.setChecked(shape.italic)
|
||||
self.size = QDoubleSpinBox()
|
||||
self.size.setRange(1, 1000)
|
||||
self.size.setValue(shape.size)
|
||||
self.text = QLineEdit(shape.text)
|
||||
self.form.addRow("Width", self.width)
|
||||
self.form.addRow("Height", self.height)
|
||||
self.form.addRow("Color", self.color)
|
||||
self.form.addRow("Bold", self.bold)
|
||||
self.form.addRow("Italic", self.italic)
|
||||
self.form.addRow("Size", self.size)
|
||||
self.form.addRow("Text", self.text)
|
||||
|
||||
def _add_line_fields(self, shape: Line) -> None:
|
||||
self.x.setMaximum(round(self._scene_rect.right()))
|
||||
self.y.setMaximum(round(self._scene_rect.bottom()))
|
||||
self.end_x = QSpinBox()
|
||||
self.end_x.setRange(round(self._scene_rect.left()), round(self._scene_rect.right()))
|
||||
self.end_x.setValue(shape.end[0])
|
||||
self.end_y = QSpinBox()
|
||||
self.end_y.setRange(round(self._scene_rect.top()), round(self._scene_rect.bottom()))
|
||||
self.end_y.setValue(shape.end[1])
|
||||
self.line_type = QComboBox()
|
||||
for line_type in LineType:
|
||||
self.line_type.addItem(line_type.value.replace("_", " ").title(), line_type)
|
||||
self.line_type.setCurrentIndex(self.line_type.findData(shape.line_type))
|
||||
self.line_thickness = QDoubleSpinBox()
|
||||
self.line_thickness.setRange(0, 1000)
|
||||
self.line_thickness.setValue(shape.line_thickness)
|
||||
self.line_color = ColorButton(shape.line_color)
|
||||
self.form.addRow("End X", self.end_x)
|
||||
self.form.addRow("End Y", self.end_y)
|
||||
self.form.addRow("Line type", self.line_type)
|
||||
self.form.addRow("Line thickness", self.line_thickness)
|
||||
self.form.addRow("Line color", self.line_color)
|
||||
73
src/bedit_gui/views/simulation_plot_widget.py
Normal file
73
src/bedit_gui/views/simulation_plot_widget.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||
from matplotlib.figure import Figure
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bedit_gui.simulation_models import SimulationPlotSettings
|
||||
from bedit_simulation import SimulationResult
|
||||
|
||||
|
||||
class SimulationPlotWidget(QWidget):
|
||||
settings_requested = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.figure = Figure(layout="constrained")
|
||||
self.canvas = FigureCanvasQTAgg(self.figure)
|
||||
self.toolbar = NavigationToolbar2QT(self.canvas, self)
|
||||
for action in self.toolbar.actions():
|
||||
if action.text() in ("Customize", "Subplots"):
|
||||
self.toolbar.removeAction(action)
|
||||
self.toolbar.addSeparator()
|
||||
self.settings_action = self.toolbar.addAction("Plot settings…")
|
||||
self.settings_action.setToolTip("Edit plot title, axes, traces, grid, and legend")
|
||||
self.settings_action.triggered.connect(self.settings_requested)
|
||||
self.axes = self.figure.add_subplot()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.toolbar)
|
||||
layout.addWidget(self.canvas)
|
||||
|
||||
def set_plot(self, results: list[SimulationResult], signals: list[str], x_axis: str | None = None, settings: SimulationPlotSettings | None = None) -> None:
|
||||
settings = settings or SimulationPlotSettings()
|
||||
self.axes.clear()
|
||||
for signal in signals:
|
||||
trace = settings.traces.get(signal)
|
||||
if trace is not None and not trace.visible:
|
||||
continue
|
||||
x_values: list[float] = []
|
||||
values: list[float] = []
|
||||
sample_offset = 0
|
||||
for result in results:
|
||||
result_values = result.data.get(signal)
|
||||
if result_values is None:
|
||||
continue
|
||||
result_x = result.data.get(x_axis or "time")
|
||||
if result_x is not None and len(result_x) == len(result_values):
|
||||
x_values.extend(result_x)
|
||||
elif x_axis is not None:
|
||||
continue
|
||||
else:
|
||||
x_values.extend(range(sample_offset, sample_offset + len(result_values)))
|
||||
values.extend(result_values)
|
||||
sample_offset += len(result_values)
|
||||
if values:
|
||||
options = {"label": trace.label or signal, "linestyle": trace.line_style, "linewidth": trace.line_width, "marker": trace.marker or None, "markersize": trace.marker_size} if trace is not None else {"label": signal}
|
||||
if trace is not None and trace.color:
|
||||
options["color"] = trace.color
|
||||
self.axes.plot(x_values, values, **options)
|
||||
self.axes.set_title(settings.title)
|
||||
self.axes.set_xlabel(settings.x_label or x_axis or "Time")
|
||||
self.axes.set_ylabel(settings.y_label)
|
||||
self.axes.set_xscale(settings.x_scale)
|
||||
self.axes.set_yscale(settings.y_scale)
|
||||
if not settings.x_auto:
|
||||
self.axes.set_xlim(settings.x_min, settings.x_max)
|
||||
if not settings.y_auto:
|
||||
self.axes.set_ylim(settings.y_min, settings.y_max)
|
||||
self.axes.grid(settings.grid_visible, axis=settings.grid_axis, linestyle=settings.grid_style, alpha=settings.grid_alpha)
|
||||
if self.axes.lines and settings.legend_visible:
|
||||
self.axes.legend(loc=settings.legend_location)
|
||||
self.canvas.draw_idle()
|
||||
13
src/bedit_gui/views/simulation_window.py
Normal file
13
src/bedit_gui/views/simulation_window.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
|
||||
from bedit_gui.ui.generated.ui_simulation_window import Ui_SimulationWindow
|
||||
|
||||
|
||||
class SimulationWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.ui = Ui_SimulationWindow()
|
||||
self.ui.setupUi(self)
|
||||
self.setWindowTitle("BEsim")
|
||||
@@ -4,8 +4,10 @@ from .openmodelica import (
|
||||
OpenModelicaError,
|
||||
OpenModelicaRunner,
|
||||
ProcessResult,
|
||||
SimulationCancelledError,
|
||||
)
|
||||
from .results import SimulationResult, load_openmodelica_csv
|
||||
from .compile import compile_component, compile_component_sync
|
||||
from .simulation import (
|
||||
ModelBuildResult,
|
||||
ModelCheckResult,
|
||||
@@ -16,19 +18,25 @@ from .simulation import (
|
||||
SimulationStateError,
|
||||
simulate,
|
||||
)
|
||||
from .runtime import SimulationRunSettings, SimulationSession
|
||||
|
||||
__all__ = [
|
||||
"OpenModelicaError",
|
||||
"OpenModelicaRunner",
|
||||
"ProcessResult",
|
||||
"ModelBuildResult",
|
||||
"ModelCheckResult",
|
||||
"ModelInfo",
|
||||
"OpenModelicaError",
|
||||
"OpenModelicaRunner",
|
||||
"ProcessResult",
|
||||
"Simulation",
|
||||
"SimulationCancelledError",
|
||||
"SimulationOptions",
|
||||
"SimulationProgress",
|
||||
"SimulationStateError",
|
||||
"SimulationResult",
|
||||
"SimulationRunSettings",
|
||||
"SimulationSession",
|
||||
"SimulationStateError",
|
||||
"compile_component",
|
||||
"compile_component_sync",
|
||||
"load_openmodelica_csv",
|
||||
"simulate",
|
||||
]
|
||||
|
||||
21
src/bedit_simulation/compile.py
Normal file
21
src/bedit_simulation/compile.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from bedit_core.models import Component
|
||||
|
||||
from .openmodelica import OpenModelicaRunner
|
||||
from .simulation import ModelBuildResult, Simulation
|
||||
|
||||
|
||||
async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult:
|
||||
"""Compose and compile a component without creating a GUI application."""
|
||||
simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout))
|
||||
await simulation.load(component)
|
||||
return await simulation.compile(working_directory)
|
||||
|
||||
|
||||
def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult:
|
||||
"""Synchronous entry point for scripts and non-async suite applications."""
|
||||
return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout))
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -22,11 +24,23 @@ class ProcessResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
def diagnostics(self) -> str:
|
||||
sections = []
|
||||
if self.stdout.strip():
|
||||
sections.append(f"OpenModelica output:\n{self.stdout.strip()}")
|
||||
if self.stderr.strip():
|
||||
sections.append(f"OpenModelica errors:\n{self.stderr.strip()}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
class OpenModelicaError(RuntimeError):
|
||||
"""Raised when OMC cannot start or reports a failure."""
|
||||
|
||||
|
||||
class SimulationCancelledError(RuntimeError):
|
||||
"""Raised when a running compiled simulation is cancelled."""
|
||||
|
||||
|
||||
ProcessExecutor = Callable[
|
||||
[Sequence[str], Path, float | None, Mapping[str, str] | None],
|
||||
ProcessResult,
|
||||
@@ -74,7 +88,7 @@ class OpenModelicaRunner:
|
||||
f"OpenModelica timed out after {error.timeout} seconds"
|
||||
) from error
|
||||
if result.return_code != 0:
|
||||
details = result.stderr.strip() or result.stdout.strip()
|
||||
details = result.diagnostics()
|
||||
suffix = f": {details}" if details else ""
|
||||
raise OpenModelicaError(
|
||||
f"OpenModelica exited with status {result.return_code}{suffix}"
|
||||
@@ -89,6 +103,58 @@ class OpenModelicaRunner:
|
||||
"""Run OMC on an asyncio worker thread."""
|
||||
return await _run_on_worker(self._run, script, working_directory)
|
||||
|
||||
def _run_cancellable(self, script: Path, working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||
command = (*self.command, str(script.resolve()))
|
||||
return self._run_cancellable_process(command, working_directory, cancel_event)
|
||||
|
||||
def _run_cancellable_process(self, command: Sequence[str], working_directory: Path, cancel_event: Event) -> ProcessResult:
|
||||
process_environment = None
|
||||
if self.environment is not None:
|
||||
process_environment = {**os.environ, **self.environment}
|
||||
try:
|
||||
process = subprocess.Popen(list(command), cwd=working_directory, env=process_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name != "nt")
|
||||
except OSError as exc:
|
||||
raise OpenModelicaError(f"simulation executable could not start: {exc}") from exc
|
||||
deadline = time.monotonic() + self.timeout if self.timeout is not None else None
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
_terminate_process(process)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_process(process)
|
||||
stdout, stderr = process.communicate()
|
||||
raise SimulationCancelledError("simulation was cancelled")
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
_kill_process(process)
|
||||
process.communicate()
|
||||
raise OpenModelicaError(f"simulation timed out after {self.timeout} seconds")
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=0.05)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr)
|
||||
if result.return_code != 0:
|
||||
details = result.diagnostics()
|
||||
suffix = f": {details}" if details else ""
|
||||
raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}")
|
||||
return result
|
||||
|
||||
|
||||
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
||||
if os.name == "nt":
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
|
||||
|
||||
def _kill_process(process: subprocess.Popen[str]) -> None:
|
||||
if os.name == "nt":
|
||||
process.kill()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
|
||||
|
||||
def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
|
||||
if isinstance(command, str):
|
||||
@@ -140,7 +206,7 @@ async def _run_on_worker(
|
||||
def invoke() -> None:
|
||||
try:
|
||||
results.append(operation(*args, **kwargs))
|
||||
except BaseException as error:
|
||||
except Exception as error: # noqa: BLE001 - worker must relay operation failures
|
||||
errors.append(error)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
70
src/bedit_simulation/runtime.py
Normal file
70
src/bedit_simulation/runtime.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .openmodelica import OpenModelicaRunner
|
||||
from .results import SimulationResult
|
||||
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationRunSettings:
|
||||
start_time: float = 0.0
|
||||
duration: float = 1.0
|
||||
use_timed_steps: bool = False
|
||||
number_of_steps: int = 500
|
||||
step_size: float = 0.002
|
||||
tolerance: float = 1e-6
|
||||
method: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.duration <= 0:
|
||||
raise ValueError("simulation duration must be positive")
|
||||
if self.number_of_steps <= 0:
|
||||
raise ValueError("number of steps must be positive")
|
||||
if self.step_size <= 0:
|
||||
raise ValueError("step size must be positive")
|
||||
if self.tolerance <= 0:
|
||||
raise ValueError("simulation tolerance must be positive")
|
||||
|
||||
|
||||
class SimulationSession:
|
||||
"""Run consecutive time ranges for one compiled model."""
|
||||
|
||||
def __init__(self, model_name: str, executable: str | Path, settings: SimulationRunSettings, *, current_end_time: float | None = None, results: Sequence[SimulationResult] = (), runner: OpenModelicaRunner | None = None) -> None:
|
||||
self.settings = settings
|
||||
self.current_end_time = settings.start_time if current_end_time is None else current_end_time
|
||||
self.results = list(results)
|
||||
self._simulation = Simulation(runner)
|
||||
self._simulation.load_compiled(model_name, executable)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._simulation.is_running
|
||||
|
||||
def get_progress(self) -> int:
|
||||
return self._simulation.get_progress()
|
||||
|
||||
async def run_next(self) -> SimulationResult:
|
||||
start_time = self.settings.start_time
|
||||
stop_time = self.current_end_time + self.settings.duration
|
||||
run_duration = stop_time - start_time
|
||||
intervals = math.ceil(run_duration / self.settings.step_size) if self.settings.use_timed_steps else math.ceil(self.settings.number_of_steps * run_duration / self.settings.duration)
|
||||
options = SimulationOptions(start_time=start_time, stop_time=stop_time, number_of_intervals=intervals, tolerance=self.settings.tolerance, method=self.settings.method)
|
||||
result = await self._simulation.run(options)
|
||||
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range.
|
||||
self.current_end_time = stop_time
|
||||
self.results[:] = [result]
|
||||
return result
|
||||
|
||||
def cancel(self) -> bool:
|
||||
return self._simulation.cancel()
|
||||
|
||||
def reset(self) -> None:
|
||||
if self.is_running:
|
||||
raise SimulationStateError("cannot reset while a simulation is running")
|
||||
self.current_end_time = self.settings.start_time
|
||||
self.results.clear()
|
||||
@@ -27,6 +27,7 @@ from .results import SimulationResult, load_openmodelica_csv
|
||||
_MODEL_FILE = "model.mo"
|
||||
_RUN_SCRIPT_FILE = "run.mos"
|
||||
_RUN_OUTPUT_FILE = "simulation-output.txt"
|
||||
_DEFAULT_LIBRARIES = ("Modelica",)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -107,6 +108,9 @@ class Simulation:
|
||||
self.last_result: SimulationResult | None = None
|
||||
self._progress = SimulationProgress()
|
||||
self._progress_lock = Lock()
|
||||
self._cancel_event = Event()
|
||||
self._running_lock = Lock()
|
||||
self._running = False
|
||||
|
||||
@staticmethod
|
||||
def _compose(component: Component) -> CompositionResult:
|
||||
@@ -179,6 +183,25 @@ class Simulation:
|
||||
with self._progress_lock:
|
||||
self._progress = progress
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
with self._running_lock:
|
||||
return self._running
|
||||
|
||||
def cancel(self) -> bool:
|
||||
"""Request cancellation of the active compiled simulation run."""
|
||||
running = self.is_running
|
||||
self._cancel_event.set()
|
||||
return running
|
||||
|
||||
def load_compiled(self, model_name: str, executable: str | Path) -> None:
|
||||
"""Load an existing compiled model without composing or compiling it."""
|
||||
executable_path = Path(executable)
|
||||
if not executable_path.is_file():
|
||||
raise ValueError(f"compiled simulation executable does not exist: {executable_path}")
|
||||
self._set_model("", model_name)
|
||||
self.last_build = ModelBuildResult(model_name=model_name, executable=executable_path, output="", errors="")
|
||||
|
||||
def _active_model(self) -> tuple[str, str]:
|
||||
if self._modelica is None or self._model_name is None:
|
||||
raise SimulationStateError(
|
||||
@@ -352,6 +375,7 @@ class Simulation:
|
||||
process = self._run_model_commands(
|
||||
modelica,
|
||||
[
|
||||
f"checkModel({model_name})",
|
||||
f'buildModel({model_name}, outputFormat="csv")',
|
||||
"getErrorString()",
|
||||
],
|
||||
@@ -363,7 +387,7 @@ class Simulation:
|
||||
if windows_executable.is_file():
|
||||
executable = windows_executable
|
||||
else:
|
||||
details = process.stderr.strip() or process.stdout.strip()
|
||||
details = process.diagnostics()
|
||||
suffix = f": {details}" if details else ""
|
||||
raise OpenModelicaError(
|
||||
f"OpenModelica did not build {model_name!r}{suffix}"
|
||||
@@ -411,14 +435,17 @@ class Simulation:
|
||||
raise SimulationStateError(
|
||||
"working_directory must be the directory used by build()"
|
||||
)
|
||||
with self._running_lock:
|
||||
if self._running:
|
||||
raise SimulationStateError("a simulation is already running")
|
||||
self._running = True
|
||||
self._set_progress(SimulationProgress())
|
||||
result = await _run_on_worker(
|
||||
self._run_built_model,
|
||||
model_name,
|
||||
build.executable,
|
||||
options,
|
||||
directory,
|
||||
)
|
||||
try:
|
||||
result = await _run_on_worker(self._run_built_model, model_name, build.executable, options, directory)
|
||||
finally:
|
||||
self._cancel_event.clear()
|
||||
with self._running_lock:
|
||||
self._running = False
|
||||
self.last_result = result
|
||||
return result
|
||||
|
||||
@@ -509,12 +536,9 @@ class Simulation:
|
||||
if options.method:
|
||||
arguments.append(f"-s={options.method}")
|
||||
script_path = directory / _RUN_SCRIPT_FILE
|
||||
script_path.write_text(
|
||||
_executable_script(arguments, directory),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script_path.write_text(_executable_script(arguments, directory), encoding="utf-8")
|
||||
try:
|
||||
process = self.runner._run(script_path, directory)
|
||||
process = self.runner._run_cancellable(script_path, directory, self._cancel_event)
|
||||
finally:
|
||||
command_finished.set()
|
||||
reader.join(timeout=20)
|
||||
@@ -649,17 +673,13 @@ async def simulate(
|
||||
)
|
||||
|
||||
|
||||
def _executable_script(
|
||||
arguments: Sequence[str],
|
||||
working_directory: Path,
|
||||
) -> str:
|
||||
def _executable_script(arguments: Sequence[str], working_directory: Path) -> str:
|
||||
"""Create an OMC script that starts a compiled simulation binary."""
|
||||
command = shlex.join(arguments)
|
||||
return "\n".join(
|
||||
[
|
||||
f"cd({json.dumps(str(working_directory.resolve()))});",
|
||||
f"status := system({json.dumps(command)}, "
|
||||
f"{json.dumps(_RUN_OUTPUT_FILE)});",
|
||||
f"status := system({json.dumps(command)}, {json.dumps(_RUN_OUTPUT_FILE)});",
|
||||
"if status <> 0 then",
|
||||
f" print(readFile({json.dumps(_RUN_OUTPUT_FILE)}));",
|
||||
" exit(1);",
|
||||
@@ -673,10 +693,20 @@ def _load_model_script(
|
||||
working_directory: Path,
|
||||
commands: Sequence[str],
|
||||
) -> str:
|
||||
"""Create a script that loads ``model.mo`` before custom commands."""
|
||||
"""Create a script that loads default libraries and ``model.mo`` before custom commands."""
|
||||
load_libraries = []
|
||||
for library in _DEFAULT_LIBRARIES:
|
||||
load_libraries.extend([
|
||||
f"loaded := loadModel({library});",
|
||||
"if not loaded then",
|
||||
" print(getErrorString());",
|
||||
" exit(1);",
|
||||
"end if;",
|
||||
])
|
||||
return "\n".join(
|
||||
[
|
||||
f"cd({json.dumps(str(working_directory.resolve()))});",
|
||||
*load_libraries,
|
||||
f"loaded := loadFile({json.dumps(_MODEL_FILE)});",
|
||||
"if not loaded then",
|
||||
" print(getErrorString());",
|
||||
|
||||
1860
untitled.bedit.json
1860
untitled.bedit.json
File diff suppressed because it is too large
Load Diff
7999
untitled.besim.json
Normal file
7999
untitled.besim.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user