eqation editor widget

This commit is contained in:
2026-07-29 18:22:38 +02:00
parent 346a963acc
commit 9df352f6b7
5 changed files with 418 additions and 2 deletions

191
AGENTS.md Normal file
View File

@@ -0,0 +1,191 @@
# 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, currently including icons and shapes.
- `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 future editors. Add a graph-editor handler later by subclassing it and registering that handler 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.
## 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 editors 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.
## 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.
## 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.

View File

@@ -5,7 +5,7 @@ from typing import Protocol
from PySide6.QtCore import QObject, QPoint, QSize, Qt from PySide6.QtCore import QObject, QPoint, QSize, Qt
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
from bedit_core.models import Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document from bedit_gui.documents import Document
from bedit_gui.models import Icon from bedit_gui.models import Icon
@@ -57,6 +57,7 @@ class DocumentTreeController(QObject):
self._components: dict[ComponentID, Component] = {} self._components: dict[ComponentID, Component] = {}
window.ui.documentTree.setModel(self.model) window.ui.documentTree.setModel(self.model)
window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed)
document.model_changed.connect(self._on_document_changed) document.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed) document.icon_changed.connect(self._on_icon_changed)
self.model.rename_document_requested.connect(self.document.rename) self.model.rename_document_requested.connect(self.document.rename)
@@ -87,6 +88,7 @@ class DocumentTreeController(QObject):
def _on_document_changed(self, model: CoreDocument) -> None: def _on_document_changed(self, model: CoreDocument) -> None:
"""Rebuild the tree whenever New/Open replaces the core document.""" """Rebuild the tree whenever New/Open replaces the core document."""
self._show_equation_component(None)
self.model.set_document(model) self.model.set_document(model)
self._components = {} self._components = {}
self._collect_components(model.root) self._collect_components(model.root)
@@ -98,6 +100,19 @@ class DocumentTreeController(QObject):
# expanded component IDs and restore only those nodes. # expanded component IDs and restore only those nodes.
self.window.ui.documentTree.expandAll() 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_equation_component(component if isinstance(component, Component) else None)
def _show_equation_component(self, component: Component | None) -> None:
if component is not None and isinstance(component.implementation, EquationImplementation):
self.window.equation_editor.set_component(component)
self.window.equation_editor.show()
else:
self.window.equation_editor.set_component(None)
self.window.equation_editor.hide()
def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None: def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None:
component = self._components.get(component_id) component = self._components.get(component_id)
if component is None: if component is None:

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>equationEditorWidget</class>
<widget class="QWidget" name="equationEditorWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1079</width>
<height>730</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="declarationEditor">
<property name="text">
<string>Declarations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="declarationsTextEdit"/>
</item>
<item>
<widget class="QLabel" name="initialEquationEditor">
<property name="text">
<string>Initial equations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="initialEquationsTextEdit"/>
</item>
<item>
<widget class="QLabel" name="equationEditor">
<property name="text">
<string>Equations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="equationsTextEdit"/>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item alignment="Qt::AlignmentFlag::AlignLeft">
<widget class="QToolButton" name="sidebarButton">
<property name="toolTip">
<string>Hide parameter and port editors</string>
</property>
<property name="arrowType">
<enum>Qt::ArrowType::RightArrow</enum>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="paramEditor">
<property name="text">
<string>paramEditor</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="portEditor">
<property name="text">
<string>portEditor</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget
from bedit_core.models import Component, EquationImplementation
from bedit_gui.ui.generated.ui_equation_editor_widget import Ui_equationEditorWidget
from bedit_gui.views.param_editor_widget import ParamEditorWidget
from bedit_gui.views.port_editor_widget import PortEditorWidget
class EquationEditorWidget(QWidget):
"""Editor that keeps an equation component synchronized with its fields."""
component_changed = Signal(object)
sidebar_visible_changed = Signal(bool)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.ui = Ui_equationEditorWidget()
self.ui.setupUi(self)
self._component: Component | None = None
self._loading = False
self.param_editor = ParamEditorWidget(self)
self.port_editor = PortEditorWidget(self)
self._replace_placeholder(self.ui.paramEditor, self.param_editor)
self._replace_placeholder(self.ui.portEditor, self.port_editor)
self.ui.sidebarButton.clicked.connect(self.toggle_sidebar)
self.set_sidebar_visible(True)
self.ui.declarationsTextEdit.textChanged.connect(self._text_changed)
self.ui.initialEquationsTextEdit.textChanged.connect(self._text_changed)
self.ui.equationsTextEdit.textChanged.connect(self._text_changed)
self.param_editor.params_changed.connect(self._params_changed)
self.port_editor.ports_changed.connect(self._ports_changed)
self._set_editors_enabled(False)
def set_component(self, component: Component | None) -> None:
if component is not None and not isinstance(component.implementation, EquationImplementation):
raise TypeError("EquationEditorWidget only supports components with an equation implementation")
self._component = component
self.refresh()
def component(self) -> Component | None:
return self._component
def toggle_sidebar(self) -> None:
self.set_sidebar_visible(not self.param_editor.isVisibleTo(self))
def set_sidebar_visible(self, visible: bool) -> None:
changed = self.param_editor.isVisibleTo(self) != visible
self.param_editor.setVisible(visible)
self.port_editor.setVisible(visible)
self.ui.sidebarButton.setArrowType(Qt.ArrowType.RightArrow if visible else Qt.ArrowType.LeftArrow)
self.ui.sidebarButton.setToolTip("Hide parameter and port editors" if visible else "Show parameter and port editors")
if changed:
self.sidebar_visible_changed.emit(visible)
def refresh(self) -> None:
"""Reload the editors after the component was changed externally."""
self._loading = True
component = self._component
if component is None:
self.ui.declarationsTextEdit.clear()
self.ui.initialEquationsTextEdit.clear()
self.ui.equationsTextEdit.clear()
self.param_editor.set_params({})
self.port_editor.set_ports({})
else:
implementation = component.implementation
assert isinstance(implementation, EquationImplementation)
self.ui.declarationsTextEdit.setPlainText("\n".join(implementation.declarations))
self.ui.initialEquationsTextEdit.setPlainText("\n".join(implementation.initial_equations))
self.ui.equationsTextEdit.setPlainText("\n".join(implementation.equations))
self.param_editor.set_params(component.parameters)
self.port_editor.set_ports(component.interface.ports)
self._loading = False
self._set_editors_enabled(component is not None)
def _text_changed(self) -> None:
if self._loading or self._component is None:
return
implementation = self._component.implementation
assert isinstance(implementation, EquationImplementation)
implementation.declarations = self.ui.declarationsTextEdit.toPlainText().splitlines()
implementation.initial_equations = self.ui.initialEquationsTextEdit.toPlainText().splitlines()
implementation.equations = self.ui.equationsTextEdit.toPlainText().splitlines()
self.component_changed.emit(self._component)
def _params_changed(self) -> None:
if self._loading or self._component is None:
return
self._component.parameters = self.param_editor.params()
self.component_changed.emit(self._component)
def _ports_changed(self) -> None:
if self._loading or self._component is None:
return
self._component.interface.ports = self.port_editor.ports()
self.component_changed.emit(self._component)
def _replace_placeholder(self, placeholder: QWidget, editor: QWidget) -> None:
self.ui.verticalLayout_2.replaceWidget(placeholder, editor)
placeholder.hide()
placeholder.deleteLater()
def _set_editors_enabled(self, enabled: bool) -> None:
self.ui.declarationsTextEdit.setEnabled(enabled)
self.ui.initialEquationsTextEdit.setEnabled(enabled)
self.ui.equationsTextEdit.setEnabled(enabled)
self.param_editor.setEnabled(enabled)
self.port_editor.setEnabled(enabled)

View File

@@ -1,7 +1,8 @@
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMainWindow, QTabWidget from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
from bedit_gui.views.equation_editor_widget import EquationEditorWidget
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -11,6 +12,12 @@ class MainWindow(QMainWindow):
self.ui = Ui_MainWindow() self.ui = Ui_MainWindow()
self.ui.setupUi(self) self.ui.setupUi(self)
central_layout = QVBoxLayout(self.ui.centralwidget)
central_layout.setContentsMargins(0, 0, 0, 0)
self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
self.equation_editor.hide()
central_layout.addWidget(self.equation_editor)
self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North)
self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea) self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)
self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea) self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)