Compare commits
54 Commits
46b8dd8263
...
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 | |||
| 713d094b08 | |||
| 36051e1577 | |||
| 8b45674cd2 | |||
| b417d85477 | |||
| d9304ba2e0 | |||
| a96bde9642 | |||
| 9312ea544b | |||
| 3bd6cfb81d | |||
| 810f993830 | |||
| f37cc41ed0 | |||
| 4c3b8b4b6d | |||
| 38b8ee34ff | |||
| e667a14459 | |||
| 0be89456f6 |
4
.gitignore
vendored
@@ -4,7 +4,9 @@ __pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/tasks.json
|
||||
.ruff_cache/
|
||||
src/bedit_gui/resources/resources_rc.py
|
||||
|
||||
39
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "BEdit debug",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "bedit_gui",
|
||||
"preLaunchTask": "Qt: Generate files",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
31
.vscode/tasks.json
vendored
@@ -2,21 +2,38 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Tests: Run pytest",
|
||||
"label": "Qt: Open Designer",
|
||||
"type": "shell",
|
||||
"command": "${command:python.interpreterPath}",
|
||||
"command": "${workspaceFolder}/.venv/bin/pyside6-designer",
|
||||
"args": [],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||
"QT_QPA_PLATFORM": "xcb"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Qt: Generate files",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/.venv/bin/python",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest"
|
||||
"${workspaceFolder}/scripts/generate_qt_files.py"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/src"
|
||||
"PATH": "${workspaceFolder}/.venv/bin:${env:PATH}"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
@@ -24,7 +41,7 @@
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
"problemMatcher": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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
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
@@ -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
@@ -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
@@ -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/signal.beb
Normal file
BIN
lib/signal_sources.beb
Normal file
@@ -17,20 +17,13 @@ dependencies = [
|
||||
[project.scripts]
|
||||
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 = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.5",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-config --strict-markers -ra"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"unit: fast tests without GUI event-loop interaction",
|
||||
"gui: tests that create or interact with Qt objects",
|
||||
]
|
||||
|
||||
78
scripts/generate_qt_files.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GUI_PACKAGE = ROOT / "src" / "bedit_gui"
|
||||
|
||||
FORMS_DIR = GUI_PACKAGE / "ui" / "forms"
|
||||
GENERATED_UI_DIR = GUI_PACKAGE / "ui" / "generated"
|
||||
|
||||
QRC_FILE = GUI_PACKAGE / "resources" / "resources.qrc"
|
||||
GENERATED_RESOURCES = (
|
||||
GUI_PACKAGE
|
||||
/ "resources"
|
||||
/ "generated"
|
||||
/ "resources_rc.py"
|
||||
)
|
||||
|
||||
RESOURCE_IMPORT = "import bedit_gui.resources.resources_rc"
|
||||
GENERATED_RESOURCE_IMPORT = (
|
||||
"from bedit_gui.resources.generated import resources_rc"
|
||||
)
|
||||
|
||||
|
||||
def execute(*command: str) -> None:
|
||||
print("+", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def generate_ui() -> None:
|
||||
GENERATED_UI_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for source in sorted(FORMS_DIR.glob("*.ui")):
|
||||
destination = GENERATED_UI_DIR / f"ui_{source.stem}.py"
|
||||
|
||||
execute(
|
||||
"pyside6-uic",
|
||||
"--absolute-imports",
|
||||
"--python-paths",
|
||||
str(ROOT / "src"),
|
||||
str(source),
|
||||
"-o",
|
||||
str(destination),
|
||||
)
|
||||
generated = destination.read_text(encoding="utf-8")
|
||||
if RESOURCE_IMPORT in generated:
|
||||
destination.write_text(
|
||||
generated.replace(
|
||||
RESOURCE_IMPORT,
|
||||
GENERATED_RESOURCE_IMPORT,
|
||||
1,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def generate_resources() -> None:
|
||||
GENERATED_RESOURCES.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
execute(
|
||||
"pyside6-rcc",
|
||||
str(QRC_FILE),
|
||||
"-o",
|
||||
str(GENERATED_RESOURCES),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
generate_ui()
|
||||
generate_resources()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)
|
||||
|
||||
@@ -95,7 +95,7 @@ class BondPort(Port):
|
||||
@dataclass
|
||||
class Parameter:
|
||||
name: str
|
||||
value: Any = 1.0
|
||||
value: Any = "1.0"
|
||||
value_type: ValueType = ValueType.REAL
|
||||
matrix_size: Annotated[list[int], 2] = field(default_factory=lambda: [1, 1])
|
||||
quantity: str | None = None
|
||||
|
||||
0
src/bedit_gui/__init__.py
Normal file
3
src/bedit_gui/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from bedit_gui.application import main
|
||||
|
||||
raise SystemExit(main())
|
||||
77
src/bedit_gui/application.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
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(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)
|
||||
settings_controller = SettingsController(window, settings)
|
||||
SimulationSettingsController(document, window)
|
||||
SimulationController(document, window)
|
||||
UndoController(document, window)
|
||||
ViewMenuController(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()
|
||||
|
||||
if args.file:
|
||||
document.open(args.file)
|
||||
else:
|
||||
document.new()
|
||||
|
||||
window.showMaximized()
|
||||
|
||||
return app.exec()
|
||||
0
src/bedit_gui/commands/__init__.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
76
src/bedit_gui/commands/param_commands.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, Parameter, ParameterID
|
||||
|
||||
|
||||
class AddParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
param: Parameter,
|
||||
) -> None:
|
||||
super().__init__("Add port")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.param = deepcopy(param)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
del self.component.parameters[self.param_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class RemoveParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
) -> None:
|
||||
super().__init__("Remove parameter")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.param = deepcopy(component.parameters[param_id])
|
||||
|
||||
def redo(self) -> None:
|
||||
del self.component.parameters[self.param_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class ChangeParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
param: Parameter,
|
||||
) -> None:
|
||||
super().__init__("Change parameter")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.old_param = deepcopy(component.parameters[param_id])
|
||||
self.new_param = deepcopy(param)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.new_param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.old_param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
76
src/bedit_gui/commands/port_commands.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, Port, PortID
|
||||
|
||||
|
||||
class AddPortCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
port_id: PortID,
|
||||
port: Port,
|
||||
) -> None:
|
||||
super().__init__("Add port")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.port_id = port_id
|
||||
self.port = deepcopy(port)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.interface.ports[self.port_id] = deepcopy(self.port)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
del self.component.interface.ports[self.port_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class RemovePortCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
port_id: PortID,
|
||||
) -> None:
|
||||
super().__init__("Remove port")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.port_id = port_id
|
||||
self.port = deepcopy(component.interface.ports[port_id])
|
||||
|
||||
def redo(self) -> None:
|
||||
del self.component.interface.ports[self.port_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.interface.ports[self.port_id] = deepcopy(self.port)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class ChangePortCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
port_id: PortID,
|
||||
port: Port,
|
||||
) -> None:
|
||||
super().__init__("Change port")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.port_id = port_id
|
||||
self.old_port = deepcopy(component.interface.ports[port_id])
|
||||
self.new_port = deepcopy(port)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.interface.ports[self.port_id] = deepcopy(self.new_port)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.interface.ports[self.port_id] = deepcopy(self.old_port)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
19
src/bedit_gui/commands/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)
|
||||
19
src/bedit_gui/commands/rename_component_command.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
from bedit_core.models import Component
|
||||
|
||||
class RenameComponentCommand(QUndoCommand):
|
||||
def __init__(self, document, component: Component, new_name: str) -> None:
|
||||
super().__init__("Rename component")
|
||||
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.old_name = component.name
|
||||
self.new_name = new_name
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.name = self.new_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.name = self.old_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
18
src/bedit_gui/commands/rename_document_command.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
|
||||
class RenameDocumentCommand(QUndoCommand):
|
||||
def __init__(self, document, new_name: str) -> None:
|
||||
super().__init__("Rename document")
|
||||
|
||||
self.document = document
|
||||
self.old_name = document.model.name
|
||||
self.new_name = new_name
|
||||
|
||||
def redo(self) -> None:
|
||||
self.document.model.name = self.new_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.document.model.name = self.old_name
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
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
@@ -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)
|
||||
0
src/bedit_gui/controllers/__init__.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())
|
||||
124
src/bedit_gui/controllers/document_controller.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject
|
||||
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.views.dialogs.document_dialogs import (
|
||||
DocumentDialogs,
|
||||
SaveChangesChoice,
|
||||
)
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DocumentDialogProvider(Protocol):
|
||||
def choose_open_path(self, current_path: Path | None) -> Path | None: ...
|
||||
|
||||
def choose_save_path(self, current_path: Path | None) -> Path | None: ...
|
||||
|
||||
def ask_save_changes(self) -> SaveChangesChoice: ...
|
||||
|
||||
def show_file_error(self, title: str, error: Exception) -> None: ...
|
||||
|
||||
|
||||
class DocumentController(QObject):
|
||||
"""Coordinates the single-document workflow with the main window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
document: Document,
|
||||
window: MainWindow,
|
||||
dialogs: DocumentDialogProvider | None = None,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
self.document = document
|
||||
self.window = window
|
||||
self.dialogs = dialogs or DocumentDialogs(window)
|
||||
|
||||
window.ui.actionNew_File.triggered.connect(self.new_document)
|
||||
window.ui.actionOpen_File.triggered.connect(self.open_document)
|
||||
window.ui.actionSave_File.triggered.connect(self.save_document)
|
||||
window.ui.actionSave_File_As.triggered.connect(self.save_document_as)
|
||||
window.ui.actionClose.triggered.connect(window.close)
|
||||
|
||||
document.path_changed.connect(self.update_window_title)
|
||||
document.modified_changed.connect(self.update_window_title)
|
||||
window.installEventFilter(self)
|
||||
self.update_window_title()
|
||||
|
||||
def new_document(self) -> None:
|
||||
if self.maybe_save_changes():
|
||||
self.document.new()
|
||||
logger.info("Created new document")
|
||||
|
||||
def open_document(self) -> None:
|
||||
if not self.maybe_save_changes():
|
||||
return
|
||||
|
||||
path = self.dialogs.choose_open_path(self.document.path)
|
||||
if path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.document.open(path)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not open document %s", path)
|
||||
self.dialogs.show_file_error("Could not open document", exc)
|
||||
else:
|
||||
logger.info("Opened document: %s", path)
|
||||
|
||||
def save_document(self) -> bool:
|
||||
if self.document.path is None:
|
||||
return self.save_document_as()
|
||||
try:
|
||||
self.document.save()
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not save document %s", self.document.path)
|
||||
self.dialogs.show_file_error("Could not save document", exc)
|
||||
return False
|
||||
logger.info("Saved document: %s", self.document.path)
|
||||
return True
|
||||
|
||||
def save_document_as(self) -> bool:
|
||||
path = self.dialogs.choose_save_path(self.document.path)
|
||||
if path is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.document.save_as(path)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.exception("Could not save document as %s", path)
|
||||
self.dialogs.show_file_error("Could not save document", exc)
|
||||
return False
|
||||
logger.info("Saved document as: %s", path)
|
||||
return True
|
||||
|
||||
def maybe_save_changes(self) -> bool:
|
||||
if not self.document.modified:
|
||||
return True
|
||||
|
||||
choice = self.dialogs.ask_save_changes()
|
||||
if choice is SaveChangesChoice.SAVE:
|
||||
return self.save_document()
|
||||
return choice is SaveChangesChoice.DISCARD
|
||||
|
||||
def update_window_title(self, *_args: object) -> None:
|
||||
name = self.document.path.name if self.document.path else "Untitled"
|
||||
marker = "*" if self.document.modified else ""
|
||||
self.window.setWindowTitle(f"{name}{marker} — BEdit")
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
window = getattr(self, "window", None)
|
||||
if (
|
||||
watched is window
|
||||
and event.type() == QEvent.Type.Close
|
||||
and not self.maybe_save_changes()
|
||||
):
|
||||
event.ignore()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
322
src/bedit_gui/controllers/document_tree_controller.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Protocol
|
||||
|
||||
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, 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: ...
|
||||
def params(self) -> dict[ParameterID, Parameter]: ...
|
||||
|
||||
|
||||
InterfaceEditorFactory = Callable[
|
||||
[dict[PortID, Port], dict[PortID, PortMetadata], MainWindow],
|
||||
InterfaceEditorLike,
|
||||
]
|
||||
|
||||
ParamEditorFactory = Callable[
|
||||
[dict[ParameterID, Parameter], MainWindow],
|
||||
ParamEditorLike
|
||||
]
|
||||
|
||||
|
||||
class DocumentTreeController(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
document: Document,
|
||||
window: MainWindow,
|
||||
interface_editor_factory: InterfaceEditorFactory = InterfaceEditorDialog,
|
||||
param_editor_factory: ParamEditorFactory = ParamEditorDialog,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.document = document
|
||||
self.window = window
|
||||
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.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)
|
||||
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")
|
||||
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(), dialog.port_metadata())
|
||||
|
||||
def _edit_params(self, component: Component) -> None:
|
||||
dialog = self.param_editor_factory(
|
||||
component.parameters,
|
||||
self.window
|
||||
)
|
||||
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
@@ -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)
|
||||
55
src/bedit_gui/controllers/log_controller.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
|
||||
|
||||
class _LogEmitter(QObject):
|
||||
message = Signal(str)
|
||||
|
||||
|
||||
class _QtLogHandler(logging.Handler):
|
||||
def __init__(self, emitter: _LogEmitter) -> None:
|
||||
super().__init__()
|
||||
self._emitter = emitter
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
self._emitter.message.emit(self.format(record))
|
||||
except (RuntimeError, TypeError, ValueError):
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
class LogController(QObject):
|
||||
"""Routes standard application log records into the log list view."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window: MainWindow | SimulationWindow,
|
||||
level: int | str = logging.INFO,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.model = LogListModel()
|
||||
self.emitter = _LogEmitter(self)
|
||||
self.handler = _QtLogHandler(self.emitter)
|
||||
self.handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
64
src/bedit_gui/controllers/settings_controller.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
from bedit_gui.services.application_logging import get_logger, set_log_level
|
||||
from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.views.dialogs.settings_dialog import SettingsDialog
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
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, 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,
|
||||
settings: ApplicationSettings,
|
||||
dialog_factory: SettingsDialogFactory = SettingsDialog,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
self.window = window
|
||||
self.settings = settings
|
||||
self.dialog_factory = dialog_factory
|
||||
|
||||
window.ui.actionSettings.triggered.connect(self.open_settings)
|
||||
|
||||
def open_settings(self) -> None:
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
51
src/bedit_gui/controllers/undo_controller.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UndoController(QObject):
|
||||
def __init__(self, document: Document, window: MainWindow) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.document = document
|
||||
self.window = window
|
||||
|
||||
undo_action = window.ui.actionUndo
|
||||
redo_action = window.ui.actionRedo
|
||||
undo_stack = document.undo_stack
|
||||
|
||||
undo_action.triggered.connect(self.undo)
|
||||
redo_action.triggered.connect(self.redo)
|
||||
|
||||
undo_stack.canUndoChanged.connect(undo_action.setEnabled)
|
||||
undo_stack.canRedoChanged.connect(redo_action.setEnabled)
|
||||
|
||||
undo_stack.undoTextChanged.connect(self._update_undo_text)
|
||||
undo_stack.redoTextChanged.connect(self._update_redo_text)
|
||||
|
||||
undo_action.setEnabled(undo_stack.canUndo())
|
||||
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)
|
||||
|
||||
def _update_undo_text(self, command: str) -> None:
|
||||
text = f"Undo {command}" if command else "Undo"
|
||||
self.window.ui.actionUndo.setText(text)
|
||||
|
||||
def _update_redo_text(self, command: str) -> None:
|
||||
text = f"Redo {command}" if command else "Redo"
|
||||
self.window.ui.actionRedo.setText(text)
|
||||
49
src/bedit_gui/controllers/view_menu_controller.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QDockWidget, QMenu, QToolBar
|
||||
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class ViewMenuController(QObject):
|
||||
"""Populate View submenus with synchronized visibility actions."""
|
||||
|
||||
def __init__(self, window: MainWindow) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.panels_menu = QMenu("Panels", window.ui.menuView)
|
||||
self.toolbars_menu = QMenu("Toolbars", window.ui.menuView)
|
||||
|
||||
window.ui.actionPanels.setMenu(self.panels_menu)
|
||||
window.ui.actionToolbars.setMenu(self.toolbars_menu)
|
||||
|
||||
self._populate(
|
||||
self.panels_menu,
|
||||
window.findChildren(QDockWidget),
|
||||
)
|
||||
self._populate(
|
||||
self.toolbars_menu,
|
||||
window.findChildren(QToolBar),
|
||||
)
|
||||
|
||||
def _populate(
|
||||
self,
|
||||
menu: QMenu,
|
||||
widgets: list[QDockWidget] | list[QToolBar],
|
||||
) -> None:
|
||||
for widget in sorted(widgets, key=self._label):
|
||||
action = widget.toggleViewAction()
|
||||
action.setText(self._label(widget))
|
||||
menu.addAction(action)
|
||||
|
||||
@staticmethod
|
||||
def _label(widget: QDockWidget | QToolBar) -> str:
|
||||
title = widget.windowTitle().strip()
|
||||
if title and title.lower() != "toolbar":
|
||||
return title
|
||||
|
||||
name = re.sub(r"(DockWidget|Widget|ToolBar)$", "", widget.objectName())
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", " ", name).title()
|
||||
37
src/bedit_gui/controllers/window_state_controller.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from PySide6.QtCore import QObject, QSettings, QByteArray
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class WindowStateController(QObject):
|
||||
def __init__(self,app: QApplication,window: MainWindow) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
self.window = window
|
||||
self.settings = QSettings()
|
||||
|
||||
# Capture the layout created by Designer.
|
||||
self._default_geometry = QByteArray(window.saveGeometry())
|
||||
self._default_state = QByteArray(window.saveState())
|
||||
|
||||
app.aboutToQuit.connect(self.save)
|
||||
window.ui.actionReset_Layout.triggered.connect(self.reset)
|
||||
|
||||
def restore(self) -> None:
|
||||
if self.settings.contains("main_window/geometry"):
|
||||
self.window.restoreGeometry(self.settings.value("main_window/geometry"))
|
||||
|
||||
if self.settings.contains("main_window/state"):
|
||||
self.window.restoreState(self.settings.value("main_window/state"))
|
||||
|
||||
def save(self) -> None:
|
||||
self.settings.setValue("main_window/geometry",self.window.saveGeometry())
|
||||
self.settings.setValue("main_window/state",self.window.saveState())
|
||||
|
||||
def reset(self) -> None:
|
||||
self.settings.remove("main_window/geometry")
|
||||
self.settings.remove("main_window/state")
|
||||
self.window.restoreGeometry(self._default_geometry)
|
||||
self.window.restoreState(self._default_state)
|
||||
self.window.showMaximized()
|
||||
3
src/bedit_gui/documents/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .document import Document
|
||||
|
||||
__all__ = ["Document"]
|
||||
488
src/bedit_gui/documents/document.py
Normal file
@@ -0,0 +1,488 @@
|
||||
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, 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
|
||||
|
||||
|
||||
class Document(QObject):
|
||||
"""The editable document currently owned by the GUI application."""
|
||||
|
||||
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)
|
||||
self.undo_stack = QUndoStack(self)
|
||||
self.undo_stack.cleanChanged.connect(self._on_clean_changed)
|
||||
self._model = self._new_model()
|
||||
self._path: Path | None = None
|
||||
self.undo_stack.setClean()
|
||||
|
||||
@property
|
||||
def model(self) -> CoreDocument:
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
return not self.undo_stack.isClean()
|
||||
|
||||
def new(self) -> None:
|
||||
self._replace(self._new_model(), None)
|
||||
|
||||
def open(self, path: str | Path) -> None:
|
||||
file_path = Path(path)
|
||||
model = document_files.load(file_path)
|
||||
self._replace(model, file_path)
|
||||
|
||||
def save(self) -> None:
|
||||
if self._path is None:
|
||||
raise ValueError("the document does not have a file path")
|
||||
document_files.save(self._model, self._path)
|
||||
self.undo_stack.setClean()
|
||||
|
||||
def save_as(self, path: str | Path) -> None:
|
||||
file_path = Path(path)
|
||||
document_files.save(self._model, file_path)
|
||||
if file_path != self._path:
|
||||
self._path = file_path
|
||||
self.path_changed.emit(file_path)
|
||||
self.undo_stack.setClean()
|
||||
|
||||
def _replace(self, model: CoreDocument, path: Path | None) -> None:
|
||||
self.undo_stack.clear()
|
||||
self._model = model
|
||||
self._path = path
|
||||
self.model_changed.emit(model)
|
||||
self.path_changed.emit(path)
|
||||
self.undo_stack.setClean()
|
||||
|
||||
def _on_clean_changed(self, clean: bool) -> None:
|
||||
self.modified_changed.emit(not clean)
|
||||
|
||||
@staticmethod
|
||||
def _new_model() -> CoreDocument:
|
||||
return CoreDocument(
|
||||
format_version=1,
|
||||
id=ID(),
|
||||
name="Untitled",
|
||||
root={},
|
||||
)
|
||||
|
||||
def rename(self, name: str) -> None:
|
||||
self.undo_stack.push(RenameDocumentCommand(self, name))
|
||||
|
||||
def rename_component(self, component: Component, name: str) -> None:
|
||||
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
||||
|
||||
def 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)
|
||||
for port_id in current.keys() - ports.keys()
|
||||
]
|
||||
added = [
|
||||
AddPortCommand(self, component, port_id, ports[port_id])
|
||||
for port_id in ports.keys() - current.keys()
|
||||
]
|
||||
changed = [
|
||||
ChangePortCommand(self, component, port_id, ports[port_id])
|
||||
for port_id in current.keys() & ports.keys()
|
||||
if current[port_id] != ports[port_id]
|
||||
]
|
||||
commands = [*removed, *added, *changed]
|
||||
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 = [
|
||||
RemoveParamCommand(self, component, param_id)
|
||||
for param_id in current.keys() - params.keys()
|
||||
]
|
||||
added = [
|
||||
AddParamCommand(self, component, param_id, params[param_id])
|
||||
for param_id in params.keys() - current.keys()
|
||||
]
|
||||
changed = [
|
||||
ChangeParamCommand(self, component, param_id, params[param_id])
|
||||
for param_id in current.keys() & params.keys()
|
||||
if current[param_id] != params[param_id]
|
||||
]
|
||||
commands = [*removed, *added, *changed]
|
||||
if not commands:
|
||||
return
|
||||
|
||||
self.undo_stack.beginMacro("Edit parameter")
|
||||
for command in commands:
|
||||
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
@@ -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()}}
|
||||
3
src/bedit_gui/resources/generated/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!__init__.py
|
||||
!.gitignore
|
||||
0
src/bedit_gui/resources/generated/__init__.py
Normal file
24
src/bedit_gui/resources/icons/AUTHORS
Normal file
@@ -0,0 +1,24 @@
|
||||
Oxygen Icon Theme has been developed by The Oxygen Team.
|
||||
|
||||
Art Directors:
|
||||
Nuno F. Pinheiro <nuno@nuno-icons.com>
|
||||
David Vignoni <david@oxygen-icons.org>
|
||||
|
||||
Naming Coordinator
|
||||
Jakob Petsovits <jpetso@gmx.at>
|
||||
|
||||
Designers:
|
||||
David J. Miller <miller@oxygen-icons.org>
|
||||
David Vignoni <david@oxygen-icons.org>
|
||||
Johann Ollivier Lapeyre <johann@oxygen-icons.org>
|
||||
Kenneth Wimer <ken@oxygen-icons.org>
|
||||
Nuno F. Pinheiro <nuno@nuno-icons.com>
|
||||
Riccardo Iaconelli <riccardo@oxygen-icons.org>
|
||||
David J. Miller <miller@oxygen-icons.org>
|
||||
|
||||
Thanks to:
|
||||
Lee Olson: Contributed drawing used in application-x-bittorent icon.
|
||||
Marco Aurélio "Coré": Improved audio-input-microphone icon.
|
||||
Matthias Kretz: Contributed "audio-input-line" device icon.
|
||||
Mauricio Piacentini <piacentini@kde.org> : game icons mashup
|
||||
Erlend Hamberg: "text-x-haskell" mimetype icon.
|
||||
216
src/bedit_gui/resources/icons/COPYING
Normal file
@@ -0,0 +1,216 @@
|
||||
The Oxygen Icon Theme
|
||||
Copyright (C) 2007 Nuno Pinheiro <nuno@oxygen-icons.org>
|
||||
Copyright (C) 2007 David Vignoni <david@icon-king.com>
|
||||
Copyright (C) 2007 David Miller <miller@oxygen-icons.org>
|
||||
Copyright (C) 2007 Johann Ollivier Lapeyre <johann@oxygen-icons.org>
|
||||
Copyright (C) 2007 Kenneth Wimer <kwwii@bootsplash.org>
|
||||
Copyright (C) 2007 Riccardo Iaconelli <riccardo@oxygen-icons.org>
|
||||
|
||||
|
||||
and others
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Clarification:
|
||||
|
||||
The GNU Lesser General Public License or LGPL is written for
|
||||
software libraries in the first place. We expressly want the LGPL to
|
||||
be valid for this artwork library too.
|
||||
|
||||
KDE Oxygen theme icons is a special kind of software library, it is an
|
||||
artwork library, it's elements can be used in a Graphical User Interface, or
|
||||
GUI.
|
||||
|
||||
Source code, for this library means:
|
||||
- where they exist, SVG;
|
||||
- otherwise, if applicable, the multi-layered formats xcf or psd, or
|
||||
otherwise png.
|
||||
|
||||
The LGPL in some sections obliges you to make the files carry
|
||||
notices. With images this is in some cases impossible or hardly useful.
|
||||
|
||||
With this library a notice is placed at a prominent place in the directory
|
||||
containing the elements. You may follow this practice.
|
||||
|
||||
The exception in section 5 of the GNU Lesser General Public License covers
|
||||
the use of elements of this art library in a GUI.
|
||||
|
||||
kde-artists [at] kde.org
|
||||
|
||||
-----
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
1
src/bedit_gui/resources/icons/README
Normal file
@@ -0,0 +1 @@
|
||||
Oxygen Icons is a freedesktop.org compatible icon theme originally developed for the KDE Plasma desktop environment in combination with the Oxygen Style. It features smooth gradients, soft shadows, and a slightly glossy look.
|
||||
BIN
src/bedit_gui/resources/icons/arrow-down.png
Normal file
|
After Width: | Height: | Size: 1006 B |
BIN
src/bedit_gui/resources/icons/arrow-up.png
Normal file
|
After Width: | Height: | Size: 927 B |
BIN
src/bedit_gui/resources/icons/configure.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/bedit_gui/resources/icons/dialog-close.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/bedit_gui/resources/icons/document-new.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/bedit_gui/resources/icons/document-open.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/bedit_gui/resources/icons/document-save-as.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/bedit_gui/resources/icons/document-save.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/bedit_gui/resources/icons/draw-bezier-curves.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/bedit_gui/resources/icons/draw-circle.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/bedit_gui/resources/icons/draw-ellipse.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/bedit_gui/resources/icons/draw-path.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/bedit_gui/resources/icons/draw-rectangle.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
src/bedit_gui/resources/icons/draw-text.png
Normal file
|
After Width: | Height: | Size: 598 B |
BIN
src/bedit_gui/resources/icons/draw-triangle.png
Normal file
|
After Width: | Height: | Size: 879 B |
BIN
src/bedit_gui/resources/icons/edit-copy.png
Normal file
|
After Width: | Height: | Size: 860 B |
BIN
src/bedit_gui/resources/icons/edit-cut.png
Normal file
|
After Width: | Height: | Size: 892 B |
BIN
src/bedit_gui/resources/icons/edit-delete.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/bedit_gui/resources/icons/edit-paste.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/bedit_gui/resources/icons/edit-redo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/bedit_gui/resources/icons/edit-select.png
Normal file
|
After Width: | Height: | Size: 991 B |
BIN
src/bedit_gui/resources/icons/edit-undo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/bedit_gui/resources/icons/list-add.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/bedit_gui/resources/icons/list-remove.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/bedit_gui/resources/icons/media-playback-start.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/bedit_gui/resources/icons/media-playback-stop.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/bedit_gui/resources/icons/media-skip-backward.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/bedit_gui/resources/icons/network-connect.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/bedit_gui/resources/icons/office-chart-line.png
Normal file
|
After Width: | Height: | Size: 877 B |
BIN
src/bedit_gui/resources/icons/preferences-system.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/bedit_gui/resources/icons/run-build.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/bedit_gui/resources/icons/transform-rotate.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/bedit_gui/resources/icons/view-form-table.png
Normal file
|
After Width: | Height: | Size: 429 B |
BIN
src/bedit_gui/resources/icons/zoom-in.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/bedit_gui/resources/icons/zoom-original.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/bedit_gui/resources/icons/zoom-out.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
40
src/bedit_gui/resources/resources.qrc
Normal file
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
<file>icons/office-chart-line.png</file>
|
||||
<file>icons/run-build.png</file>
|
||||
<file>icons/preferences-system.png</file>
|
||||
<file>icons/draw-triangle.png</file>
|
||||
<file>icons/draw-ellipse.png</file>
|
||||
<file>icons/draw-circle.png</file>
|
||||
<file>icons/draw-path.png</file>
|
||||
<file>icons/network-connect.png</file>
|
||||
<file>icons/arrow-down.png</file>
|
||||
<file>icons/configure.png</file>
|
||||
<file>icons/arrow-up.png</file>
|
||||
<file>icons/document-new.png</file>
|
||||
<file>icons/document-open.png</file>
|
||||
<file>icons/document-save-as.png</file>
|
||||
<file>icons/document-save.png</file>
|
||||
<file>icons/draw-bezier-curves.png</file>
|
||||
<file>icons/draw-rectangle.png</file>
|
||||
<file>icons/draw-text.png</file>
|
||||
<file>icons/edit-copy.png</file>
|
||||
<file>icons/edit-cut.png</file>
|
||||
<file>icons/edit-paste.png</file>
|
||||
<file>icons/edit-redo.png</file>
|
||||
<file>icons/edit-select.png</file>
|
||||
<file>icons/edit-undo.png</file>
|
||||
<file>icons/media-playback-start.png</file>
|
||||
<file>icons/transform-rotate.png</file>
|
||||
<file>icons/zoom-in.png</file>
|
||||
<file>icons/zoom-original.png</file>
|
||||
<file>icons/zoom-out.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
0
src/bedit_gui/services/__init__.py
Normal file
32
src/bedit_gui/services/application_logging.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
LOGGER_NAMESPACE = "bedit"
|
||||
|
||||
_application_logger = logging.getLogger(LOGGER_NAMESPACE)
|
||||
_application_logger.propagate = False
|
||||
_application_logger.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Return a logger routed to the BEdit application log."""
|
||||
return logging.getLogger(f"{LOGGER_NAMESPACE}.{name}")
|
||||
|
||||
|
||||
def configure_logging(
|
||||
handler: logging.Handler,
|
||||
level: int | str = logging.INFO,
|
||||
) -> None:
|
||||
"""Replace the application output handler and set its log level."""
|
||||
for existing in list(_application_logger.handlers):
|
||||
_application_logger.removeHandler(existing)
|
||||
_application_logger.addHandler(handler)
|
||||
set_log_level(level)
|
||||
|
||||
|
||||
def set_log_level(level: int | str) -> None:
|
||||
"""Set the minimum severity shown by all BEdit loggers."""
|
||||
if isinstance(level, str):
|
||||
level = level.upper()
|
||||
_application_logger.setLevel(level)
|
||||
72
src/bedit_gui/services/application_settings.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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()
|
||||