diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a56bc9 --- /dev/null +++ b/AGENTS.md @@ -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 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. + +## 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. diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index ca3f8ca..2f92ed5 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -5,7 +5,7 @@ from typing import Protocol from PySide6.QtCore import QObject, QPoint, QSize, Qt 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_gui.documents import Document from bedit_gui.models import Icon @@ -57,6 +57,7 @@ class DocumentTreeController(QObject): self._components: dict[ComponentID, Component] = {} window.ui.documentTree.setModel(self.model) + window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed) document.model_changed.connect(self._on_document_changed) document.icon_changed.connect(self._on_icon_changed) self.model.rename_document_requested.connect(self.document.rename) @@ -87,6 +88,7 @@ class DocumentTreeController(QObject): def _on_document_changed(self, model: CoreDocument) -> None: """Rebuild the tree whenever New/Open replaces the core document.""" + self._show_equation_component(None) self.model.set_document(model) self._components = {} self._collect_components(model.root) @@ -98,6 +100,19 @@ class DocumentTreeController(QObject): # 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_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: component = self._components.get(component_id) if component is None: diff --git a/src/bedit_gui/ui/forms/equation_editor_widget.ui b/src/bedit_gui/ui/forms/equation_editor_widget.ui new file mode 100644 index 0000000..6e3429a --- /dev/null +++ b/src/bedit_gui/ui/forms/equation_editor_widget.ui @@ -0,0 +1,86 @@ + + + equationEditorWidget + + + + 0 + 0 + 1079 + 730 + + + + Form + + + + + + + + Declarations + + + + + + + + + + Initial equations + + + + + + + + + + Equations + + + + + + + + + + + + + + Hide parameter and port editors + + + Qt::ArrowType::RightArrow + + + true + + + + + + + paramEditor + + + + + + + portEditor + + + + + + + + + + diff --git a/src/bedit_gui/views/equation_editor_widget.py b/src/bedit_gui/views/equation_editor_widget.py new file mode 100644 index 0000000..8e5a75d --- /dev/null +++ b/src/bedit_gui/views/equation_editor_widget.py @@ -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) diff --git a/src/bedit_gui/views/main_window.py b/src/bedit_gui/views/main_window.py index 1a3d1dd..4320db5 100644 --- a/src/bedit_gui/views/main_window.py +++ b/src/bedit_gui/views/main_window.py @@ -1,7 +1,8 @@ from PySide6.QtCore import Qt -from PySide6.QtWidgets import QMainWindow, QTabWidget +from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow +from bedit_gui.views.equation_editor_widget import EquationEditorWidget class MainWindow(QMainWindow): @@ -11,6 +12,12 @@ class MainWindow(QMainWindow): self.ui = Ui_MainWindow() self.ui.setupUi(self) + central_layout = QVBoxLayout(self.ui.centralwidget) + central_layout.setContentsMargins(0, 0, 0, 0) + self.equation_editor = EquationEditorWidget(self.ui.centralwidget) + self.equation_editor.hide() + central_layout.addWidget(self.equation_editor) + self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea) self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)