diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 930acc5..88a22af 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -162,6 +162,9 @@ runtime. A tiny generic prompt with one field and OK/Cancel may remain code-only ## Document and library files - Documents use the `bedit-document` JSON format. +- Documents can be stored as human-readable `.bedit.json`/`.json` through + `JsonDocumentSerializer`, or as compressed MessagePack `.beb` through + `BebDocumentSerializer`. UI document I/O dispatches via `DocumentSerializer`. - `test.bedit.json` is a useful manually created example during development. - Library documents use the same recursive document model. - Library parsing belongs in `core/libraries.py`; Qt change notifications belong diff --git a/BEdit/README.md b/BEdit/README.md index 794e7e8..41dc0b2 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -146,7 +146,7 @@ BEdit document used as a copy source. The built-in example defines A, B, and C. - File → Close Document removes the active document and returns to an empty workspace. An open graph uses a light gray grid whose visible and snapping spacing are configured separately. -- Edit → Settings → Libraries accepts document files or folders of JSON files. +- Edit → Settings → Libraries accepts JSON or `.beb` document files and folders. Every component owns its ports, declarative icon, properties, and child graph: diff --git a/BEdit/pyproject.toml b/BEdit/pyproject.toml index 58f9e45..43133fc 100644 --- a/BEdit/pyproject.toml +++ b/BEdit/pyproject.toml @@ -9,6 +9,7 @@ description = "A starter Qt desktop application" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "msgpack>=1.0,<2", "PySide6>=6.7,<7", ] @@ -25,7 +26,7 @@ bedit = "bedit.gui.app:main" where = ["src"] [tool.setuptools.package-data] -bedit = ["data/libraries/*.json"] +bedit = ["data/libraries/*.json", "data/libraries/*.beb"] [tool.ruff] line-length = 100 diff --git a/BEdit/src/bedit/core/libraries.py b/BEdit/src/bedit/core/libraries.py index 0d3d51d..a5beb2f 100644 --- a/BEdit/src/bedit/core/libraries.py +++ b/BEdit/src/bedit/core/libraries.py @@ -1,8 +1,8 @@ -import json from dataclasses import dataclass from pathlib import Path from bedit.core.model import GraphDocument +from bedit.core.serializer import DocumentSerializer @dataclass(frozen=True) @@ -13,7 +13,7 @@ class LibraryDocument: def bundled_library_path() -> Path: - return Path(__file__).resolve().parents[1] / "data" / "libraries" / "default.json" + return Path(__file__).resolve().parents[1] / "data" / "libraries" / "default.beb" def default_library_paths() -> list[str]: @@ -21,12 +21,12 @@ def default_library_paths() -> list[str]: def load_library_file(path: Path) -> LibraryDocument: - with path.open(encoding="utf-8") as file: - data = json.load(file) - document = GraphDocument.from_dict(data) + document = DocumentSerializer.load(path) name = str(document.metadata.get("name") or path.stem) return LibraryDocument(name, document, str(path)) def library_candidates(path: Path) -> list[Path]: - return sorted(path.glob("*.json")) if path.is_dir() else [path] + if not path.is_dir(): + return [path] + return sorted((*path.glob("*.json"), *path.glob("*.beb"))) diff --git a/BEdit/src/bedit/core/serializer.py b/BEdit/src/bedit/core/serializer.py index e9e720a..fb592ac 100644 --- a/BEdit/src/bedit/core/serializer.py +++ b/BEdit/src/bedit/core/serializer.py @@ -1,6 +1,9 @@ import json +import zlib from pathlib import Path +import msgpack + from bedit.core.model import GraphDocument @@ -20,3 +23,54 @@ class JsonDocumentSerializer: json.dump(document.to_dict(), file, indent=2) file.write("\n") temporary_path.replace(path) + + +class BebDocumentSerializer: + """Compressed MessagePack serializer for the binary .beb format.""" + + MAGIC = b"BEB\x00" + VERSION = 1 + + @classmethod + def load(cls, path: Path) -> GraphDocument: + payload = path.read_bytes() + header = cls.MAGIC + bytes([cls.VERSION]) + if not payload.startswith(header): + raise ValueError("This is not a supported BEdit binary document") + try: + data = msgpack.unpackb(zlib.decompress(payload[len(header) :]), raw=False) + except (ValueError, zlib.error, msgpack.exceptions.MsgpackException) as error: + raise ValueError("The BEdit binary document is damaged") from error + if not isinstance(data, dict): + raise ValueError("The BEdit binary document has an invalid root value") + return GraphDocument.from_dict(data) + + @classmethod + def save(cls, document: GraphDocument, path: Path) -> None: + packed = msgpack.packb(document.to_dict(), use_bin_type=True) + payload = cls.MAGIC + bytes([cls.VERSION]) + zlib.compress(packed, level=9) + temporary_path = path.with_suffix(path.suffix + ".tmp") + temporary_path.write_bytes(payload) + temporary_path.replace(path) + + +class DocumentSerializer: + """Select the document serializer from its file extension.""" + + @staticmethod + def load(path: Path) -> GraphDocument: + serializer = ( + BebDocumentSerializer + if path.suffix.lower() == ".beb" + else JsonDocumentSerializer + ) + return serializer.load(path) + + @staticmethod + def save(document: GraphDocument, path: Path) -> None: + serializer = ( + BebDocumentSerializer + if path.suffix.lower() == ".beb" + else JsonDocumentSerializer + ) + serializer.save(document, path) diff --git a/BEdit/src/bedit/data/libraries/default.beb b/BEdit/src/bedit/data/libraries/default.beb new file mode 100644 index 0000000..a467ebe Binary files /dev/null and b/BEdit/src/bedit/data/libraries/default.beb differ diff --git a/BEdit/src/bedit/data/libraries/default.json b/BEdit/src/bedit/data/libraries/default.json deleted file mode 100644 index 7eb0e76..0000000 --- a/BEdit/src/bedit/data/libraries/default.json +++ /dev/null @@ -1,435 +0,0 @@ -{ - "format": "bedit-document", - "version": 1, - "metadata": { - "name": "Untitled" - }, - "roots": [ - { - "id": "7ad651ef-6f76-4064-81d3-3fd6dd4d2918", - "name": "gain", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-df34ce84", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-fe9e6486", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 88.0, - "y": 40.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "type": "rectangle", - "x": 32.0, - "y": 32.0, - "width": 64.0, - "height": 64.0, - "fill": "#f4f4f4", - "stroke": "#303030", - "lineWidth": 1.5, - "lineStyle": "solid", - "cornerRadius": 5.0 - }, - { - "type": "text", - "x": 40.0, - "y": 40.0, - "width": 48.0, - "height": 48.0, - "text": "K", - "color": "#00007f", - "fontSize": 24.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#00007f", - "fill": "#ffffff" - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "out = k*in;", - "parameters": [ - { - "id": "parameter-61a43a86", - "name": "k", - "type": "real", - "value": "1" - } - ] - } - } - }, - { - "id": "12b21b11-8828-4a90-a561-19491dc9632e", - "name": "differentiate", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-1cbabc8f", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-4eeea4e7", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 88.0, - "y": 40.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "type": "rectangle", - "x": 32.0, - "y": 32.0, - "width": 64.0, - "height": 64.0, - "fill": "#f4f4f4", - "stroke": "#303030", - "lineWidth": 1.5, - "lineStyle": "solid", - "cornerRadius": 5.0 - }, - { - "type": "text", - "x": 40.0, - "y": 40.0, - "width": 48.0, - "height": 48.0, - "text": "d/dt", - "color": "#00007f", - "fontSize": 18.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#00007f", - "fill": "#ffffff" - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "initial out = initial;\nout = der(in);", - "parameters": [ - { - "id": "parameter-331e38be", - "name": "initial", - "type": "real", - "value": "0" - } - ] - } - } - }, - { - "id": "afdc7696-622b-47e4-b2c3-28ec60786bf2", - "name": "integrate", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-12903207", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ], - "outputs": [ - { - "id": "port-b3370b1a", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 88.0, - "y": 40.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "type": "rectangle", - "x": 32.0, - "y": 32.0, - "width": 64.0, - "height": 64.0, - "fill": "#f4f4f4", - "stroke": "#303030", - "lineWidth": 1.5, - "lineStyle": "solid", - "cornerRadius": 5.0 - }, - { - "type": "text", - "x": 64.0, - "y": 40.0, - "width": 28.0, - "height": 48.0, - "text": "dt", - "color": "#00007f", - "fontSize": 18.0, - "lineStyle": "solid", - "lineWidth": 1.5, - "stroke": "#00007f", - "fill": "#ffffff" - }, - { - "type": "text", - "x": 40.0, - "y": 32.0, - "width": 28.0, - "height": 54.0, - "fill": "none", - "stroke": "#00007f", - "lineWidth": 1.5, - "lineStyle": "none", - "text": "\u222b", - "fontSize": 32.0, - "color": "#00007f" - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "initial out = initial;\nder(out) = in;", - "parameters": [ - { - "id": "parameter-6bdc1c76", - "name": "initial", - "type": "real", - "value": "0" - } - ] - } - } - }, - { - "id": "f6b8e5c9-4d13-4f7b-b574-e00ca96e42c3", - "name": "add", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "port-c995845c", - "name": "in", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 64.0, - "y": 64.0 - } - }, - "type": "signal", - "multipleConnections": true - } - ], - "outputs": [ - { - "id": "port-a2f0dea6", - "name": "out", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": { - "iconPosition": { - "x": 80.0, - "y": 48.0 - } - }, - "type": "signal", - "multipleConnections": false - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text", - "size": { - "width": 128.0, - "height": 128.0 - }, - "elements": [ - { - "type": "circle", - "x": 40.0, - "y": 40.0, - "width": 48.0, - "height": 48.0, - "fill": "#f4f4f4", - "stroke": "#303030", - "lineWidth": 1.5, - "lineStyle": "solid" - }, - { - "type": "text", - "x": 48.0, - "y": 48.0, - "width": 32.0, - "height": 32.0, - "fill": "none", - "stroke": "#00007f", - "lineWidth": 1.5, - "lineStyle": "none", - "text": "+", - "fontSize": 18.0, - "color": "#00007f" - } - ] - }, - "properties": { - "showName": false - }, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": "out = sum(in[i] for i in 1:in.N);", - "parameters": [] - } - } - } - ] -} diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index 8af72ab..22d0865 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -40,7 +40,7 @@ from bedit.core.model import ( ) from bedit.core.simulation import Simulation from bedit.core.port_types import PortTypeRegistry -from bedit.core.serializer import JsonDocumentSerializer +from bedit.core.serializer import DocumentSerializer class DocumentController(QObject): @@ -108,7 +108,7 @@ class DocumentController(QObject): self.filePathChanged.emit(None) def load(self, path: Path) -> None: - self.document = JsonDocumentSerializer.load(path) + self.document = DocumentSerializer.load(path) self.active_component_id = next(iter(self.document.roots), None) self.file_path = path self.undo_stack.clear() @@ -124,7 +124,7 @@ class DocumentController(QObject): target = path or self.file_path if target is None: raise ValueError("No file path has been selected") - JsonDocumentSerializer.save(self.document, target) + DocumentSerializer.save(self.document, target) self.file_path = target self.undo_stack.setClean() self.filePathChanged.emit(target) diff --git a/BEdit/src/bedit/gui/dialogs/settings.py b/BEdit/src/bedit/gui/dialogs/settings.py index 201f4e2..9592ab1 100644 --- a/BEdit/src/bedit/gui/dialogs/settings.py +++ b/BEdit/src/bedit/gui/dialogs/settings.py @@ -3,7 +3,7 @@ from pathlib import Path from PySide6.QtCore import QSettings, Signal from PySide6.QtWidgets import QDialog, QFileDialog -from bedit.core.libraries import default_library_paths +from bedit.core.libraries import bundled_library_path, default_library_paths from bedit.gui.preferences import application_settings from bedit.gui.generated.ui_settings_dialog import Ui_SettingsDialog @@ -56,8 +56,22 @@ class SettingsDialog(QDialog): settings = settings if settings is not None else application_settings() value = settings.value("libraries/paths", default_library_paths()) if isinstance(value, str): - return [value] - return [str(path) for path in value] + paths = [value] + else: + paths = [str(path) for path in value] + bundled = bundled_library_path() + migrated = [ + str(bundled) + if not Path(path).exists() + and Path(path).parent == bundled.parent + and Path(path).suffix.lower() == ".json" + else path + for path in paths + ] + if migrated != paths: + settings.setValue("libraries/paths", migrated) + settings.sync() + return migrated @staticmethod def graph_grid_size(settings: QSettings | None = None) -> int: @@ -79,7 +93,7 @@ class SettingsDialog(QDialog): self, "Add library", "", - "BEdit libraries (*.json);;All files (*)", + "BEdit libraries (*.beb *.bedit.json *.json);;All files (*)", ) if path: self._append_unique_path(path) diff --git a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py index 6c48978..9427d28 100644 --- a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py +++ b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py @@ -176,7 +176,7 @@ class Ui_SettingsDialog(object): self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None)) self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None)) self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None)) - self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None)) + self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON or BEdit Binary files from these files or folders at startup:", None)) self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None)) self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None)) self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None)) diff --git a/BEdit/src/bedit/gui/main_window.py b/BEdit/src/bedit/gui/main_window.py index 9196206..844ef74 100644 --- a/BEdit/src/bedit/gui/main_window.py +++ b/BEdit/src/bedit/gui/main_window.py @@ -14,7 +14,7 @@ from PySide6.QtWidgets import ( from bedit.core.model import Component, Parameter from bedit.core.application_log import get_logger -from bedit.core.serializer import JsonDocumentSerializer +from bedit.core.serializer import DocumentSerializer from bedit.core.simulation import Simulation from bedit.gui.controllers.document import DocumentController from bedit.gui.dialogs.component_options import ComponentOptionsDialog @@ -407,7 +407,11 @@ class MainWindow(QMainWindow): if not self._resolve_source_edits() or not self._maybe_save(): return filename, _ = QFileDialog.getOpenFileName( - self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)" + self, + "Open graph", + "", + "BEdit documents (*.bedit.json *.json *.beb);;" + "BEdit JSON (*.bedit.json *.json);;BEdit Binary (*.beb);;All files (*)", ) if not filename: return @@ -437,16 +441,21 @@ class MainWindow(QMainWindow): def save_document_as(self) -> bool: if self.document_controller.document is None: return False - filename, _ = QFileDialog.getSaveFileName( + filename, selected_filter = QFileDialog.getSaveFileName( self, "Save graph", - "untitled.bedit.json", - "BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)", + "untitled.beb", + "BEdit Binary (*.beb);;BEdit JSON (*.bedit.json *.json);;All files (*)", ) if not filename: return False + path = Path(filename) + if path.suffix.lower() not in {".json", ".beb"}: + path = path.with_suffix( + ".beb" if "Binary" in selected_filter else ".bedit.json" + ) try: - path = self.document_controller.save(Path(filename)) + path = self.document_controller.save(path) self.log.info("Saved document %s", path) except OSError as error: self.log.error("Could not save document %s: %s", filename, error) @@ -557,7 +566,7 @@ class MainWindow(QMainWindow): try: if library is not None: library.document.validate() - JsonDocumentSerializer.save(library.document, Path(library.source_path)) + DocumentSerializer.save(library.document, Path(library.source_path)) except (OSError, ValueError) as error: component.inputs, component.outputs = old_inputs, old_outputs self.log.error("Could not change library ports: %s", error) diff --git a/BEdit/src/bedit/gui/models/library_repository.py b/BEdit/src/bedit/gui/models/library_repository.py index df37009..6abc5ad 100644 --- a/BEdit/src/bedit/gui/models/library_repository.py +++ b/BEdit/src/bedit/gui/models/library_repository.py @@ -1,4 +1,3 @@ -import json from pathlib import Path from PySide6.QtCore import QObject, Signal @@ -23,7 +22,7 @@ class LibraryRepository(QObject): for candidate in library_candidates(path): try: libraries.append(load_library_file(candidate)) - except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + except (OSError, ValueError, KeyError, TypeError) as error: warnings.append(f"{candidate}: {error}") self.libraries = libraries self.load_warnings = warnings diff --git a/BEdit/ui/settings_dialog.ui b/BEdit/ui/settings_dialog.ui index ea38e60..1625988 100644 --- a/BEdit/ui/settings_dialog.ui +++ b/BEdit/ui/settings_dialog.ui @@ -94,7 +94,7 @@ - Load library JSON files from these files or folders at startup: + Load library JSON or BEdit Binary files from these files or folders at startup: true