ge-qml

PySide6 + QML desktop application — the QML port of ge-py. Same DSM model, same generated ge/ package, same hand-written model/ business logic, same CommitStore facade. The UI is QML, driven by Python QObject models registered as QML context properties. Built on the Qt Quick variant of dsviper-components.

  • Source repositorydigital-substrate/ge-qml.

  • Entry pointgraph_editor.py (shim that runs graph_editor/main.py).

  • DependenciesPySide6 (Qt Quick + Quick Controls + Dialogs), dsviper (from PyPI).

What it demonstrates

ge-qml is the same value-chain walk-through as ge-py, swapping Qt Widgets for Qt Quick:

Layer

Where in ge-qml

DevKit doc

DSM model

DSM definitions of the Graph

DSM

Code-gen

graph_editor/ge/ package (Kibo output)

Kibo

Runtime

dsviper.CommitStore usage

dsviper

Shared QML

dsviper_components_qml/ (vendored copy)

dsviper-components

The interesting comparison with ge-py is what changes — and what doesn’t — when the UI moves from imperative widgets to declarative QML. model/, ge/, and Context are essentially identical to ge-py: the entire business stack is reused.

Architecture

QML adds one layer above ge-py’s five-layer stack — a thin QObject bridge that exposes the application state to QML through Qt properties and slots. Six layers in total:

┌─────────────────────────────────────────────────────────────┐
│  1. UI Layer (QML)                                          │
│     Main.qml + GraphVertexPanel.qml, GraphListPanel.qml, …  │
├─────────────────────────────────────────────────────────────┤
│  2. QObject Bridge (Python)                                 │
│     vertex_model.py, list_model.py, render_model.py, …      │
│     Properties + Slots, registered as QML context props     │
├─────────────────────────────────────────────────────────────┤
│  3. CommitStore Facade                                      │
│     model/context.py — singleton wrapping CommitStore       │
├─────────────────────────────────────────────────────────────┤
│  4. Business Logic (hand-written Python)                    │
│     model/*.py — vertex.py, graph.py, selection_*.py, …     │
├─────────────────────────────────────────────────────────────┤
│  5. Generated Data (Kibo output)                            │
│     ge/*.py — data, attachments, definitions, value_type    │
├─────────────────────────────────────────────────────────────┤
│  6. dsviper Runtime                                         │
│     CommitDatabase, CommitStore, CommitMutableState, Value  │
└─────────────────────────────────────────────────────────────┘

The bridge layer is what makes a QML application different from a Widgets application — and why the model/ and ge/ layers are bit-for-bit shareable with ge-py.

Repository layout

ge-qml/
├── graph_editor.py             # Entry-point shim — runs graph_editor/main.py
├── graph_editor/               # Application package
│   ├── main.py                 # QApplication + QQmlApplicationEngine setup
│   ├── Main.qml                # Root window (menus, layout)
│   ├── Graph*Panel.qml         # Domain panels (vertex, list, tags, comments, render)
│   ├── *_model.py              # QObject bridges exposed to QML
│   ├── transient_notifier.py   # Live-preview channel (illusion pattern)
│   ├── ge/                     # Kibo-generated infrastructure (same as ge-py)
│   ├── model/                  # Hand-written business logic (same as ge-py)
│   │   ├── context.py          # Singleton: store + graph_key + facade
│   │   ├── graph.py, vertex.py, edge.py, …
│   │   └── script_*.py         # Reusable scripts
│   ├── render/                 # 2-D canvas (paint, hit-testing)
│   ├── list/                   # List-view items
│   ├── scripts/                # User-editable Python scripts (run from the embedded editor)
│   └── images/                 # App icon and assets
└── dsviper_components_qml/     # Vendored copy of dsviper-components-qml
                                #   (synced with dev/sync_dsviper_components_qml.py)

The shim at the repo root keeps python3 graph_editor.py working from any directory; the real entry point is graph_editor/main.py.

The Context singleton

graph_editor/model/context.py is identical in spirit (and almost character-for-character) to ge-py’s Context. Same singleton, same CommitStore, same Graph_GraphKey, same use(database) lifecycle, same dispatch / undo / redo facade for scripting.

from dsviper import CommitStore, CommitDatabase, CommitState, CommitMutableState
from ge import attachments, definitions
from ge.data import Graph_GraphKey
from model import graph


class Context:

    @classmethod
    def instance(cls) -> "Context":
        if not hasattr(cls, "_instance"):
            setattr(cls, "_instance", cls())
        return getattr(cls, "_instance")

    def __init__(self):
        self.store = CommitStore()
        self.graph_key = Graph_GraphKey.create()

    def use(self, database: CommitDatabase):
        if not database.commit_ids():
            self._create_initial_commit(database, database.initial_state())
        commit_id = database.last_commit_id()
        self.store.set_state(database.state(commit_id))
        self.store.set_database(database)
        self.store.notify_database_did_open()
        self.load()

That this file is essentially copy-pasted between ge-py and ge-qml is the point: the Commit-facing layer is UI-agnostic. Switching toolkits costs you the bridge and the views, not the application core.

The dispatch pattern

Every mutation still goes through store.dispatch(label, callable) — but in QML, the trigger lives in a @Slot on a QObject model, called from QML, instead of in a widget event handler:

# vertex_model.py — value setter exposed to QML
@Slot(int)
def setValue(self, new_value: int):
    if not self._vertex_key:
        return
    label = f"Set Value '{new_value}' For Vertex '{self._value}'"
    self._context.store.dispatch(
        label,
        lambda m: attachments.graph_vertex_visual_attributes_set_value(
            m, self._vertex_key, new_value))
// GraphVertexPanel.qml
SpinBox {
    value: vertexModel.value
    onValueModified: vertexModel.setValue(value)
}

The body of the lambda is plain Python that calls into model/ and the generated ge.attachments API — exactly as in ge-py. The only difference is the firing path: QML ➔ Slot ➔ dispatch ➔ business logic.

Tip

The dispatch label still drives the undo stack and the commit history. The QML port keeps the same convention — labels describe user intent ("Set Value '7' For Vertex '3'"), not implementation steps.

The QObject bridge — *_model.py

This is the layer ge-py does not have. Each *_model.py is a QObject subclass that:

  • exposes view-state as Qt Properties with notify signals — QML bindings track them automatically;

  • exposes user actions as @Slot methods — QML calls them by name;

  • subscribes to CommitStore notifications (state_did_change, database_did_open, database_did_close) and re-reads the state every time the store changes.

class VertexModel(QObject):
    valueChanged = Signal()

    def __init__(self, notifier, parent=None):
        super().__init__(parent)
        self._context = Context.instance()
        notifier.state_did_change.connect(self._configure)

    def _get_value(self) -> int:
        return self._value

    value = Property(int, _get_value, notify=valueChanged)

    @Slot(int)
    def setValue(self, new_value: int):
        self._context.store.dispatch(
            f"Set Value '{new_value}'",
            lambda m: attachments.graph_vertex_visual_attributes_set_value(
                m, self._vertex_key, new_value))

    def _configure(self):
        # re-read state on every notification, emit *Changed signals
        ...

main.py instantiates each model with the notifier from CommitAdminModel.notifier and wires it to QML:

ctx = engine.rootContext()
ctx.setContextProperty("vertexModel", VertexModel(notifier))
ctx.setContextProperty("listModel", ListModel(notifier))
ctx.setContextProperty("renderModel", RenderModel(notifier))

QML files reference these by name (vertexModel.value, listModel.entries) — no Python imports, no QML/C++ type registration in the application itself.

Live preview — the TransientNotifier

QML controls (sliders, color pickers, draggable handles) emit a flood of intermediate values that should not each become a commit. ge-qml uses a transient channelTransientNotifier — for the in-flight values, and only calls dispatch on release.

@Slot(QColor)
def previewColor(self, color: QColor):  # while the picker is open
    TransientNotifier.instance().notify_vertex_color(self._vertex_key, color)


@Slot(QColor)
def setColor(self, new_color: QColor):  # when the user accepts
    self._context.store.dispatch(
        f"Set Color For Vertex '{self._value}'",
        lambda m: attachments.graph_vertex_visual_attributes_set_color(
            m, self._vertex_key, _to_graph_color(new_color)))

Render and panels subscribe to TransientNotifier for the live preview and to the store for the committed state. The two channels never mix — only dispatch writes to the DAG.

Business logic — model/

Identical role and almost identical code to ge-py: pure-Python functions taking an AttachmentMutating plus the keys/values they need, returning the keys they create.

# model/vertex.py
def add(attachment_mutating: AttachmentMutating,
        graph_key: Graph_GraphKey,
        value: int,
        position: Graph_Position,
        color: Graph_Color) -> Graph_VertexKey:
    vertex_key = create(attachment_mutating, value, position, color)

    vertex_keys = Set_Graph_VertexKey()
    vertex_keys.add(vertex_key)
    attachments.graph_graph_topology_union_vertex_keys(
        attachment_mutating, graph_key, vertex_keys)

    return vertex_key

model/ modules import from ge/ (the generated layer); the bridge models import from model/ and ge/. The dependency graph still flows in one direction.

Generated data — ge/

graph_editor/ge/ is the Kibo Python output for the Graph DSM model — same structure as ge-py:

File

Purpose

ge/data.py

Concept keys (Graph_VertexKey), structures, enums

ge/attachments.py

Typed accessors (graph_vertex_visual_attributes_set)

ge/definitions.py

Embedded DSM definitions, loaded into the database

ge/value_type.py

Type registry helpers

ge/path.py

Field paths

ge/resources.py

Embedded definitions (base64 blob)

Regenerated from the DSM model and never edited by hand. The mapping follows Kibo’s naming conventions.

Notification flow

QML’s automatic property bindings make the redraw step implicit: once a bridge model emits xChanged, every QML expression reading model.x re-evaluates, and the affected items repaint. The flow:

QML control  ──►  model.someSlot(value)
                       │
                       ▼
                 store.dispatch(label, λ)
                       │
                       ▼
                  λ(mutating)            ← business logic runs here
                       │
                       ▼
            store.commit_mutations()     ← persisted to the DAG
                       │
                       ▼
       store.notify_state_did_change()   ← Python signal
                       │
                       ▼
       VertexModel._configure()          ← re-reads state, emits Changed
                       │
                       ▼
        QML bindings re-evaluate         ← UI updates implicitly

The bridge is where the imperative Python world meets the declarative QML world. Below it, everything is identical to ge-py.

What dsviper-components-qml ships with the application

As in ge-py, the application inherits a complete suite of administration, collaboration and scripting features by importing from dsviper-components-qml. None of this code lives in ge-qml — main.py instantiates the model classes and exposes them to QML; the QML side does no Python-specific wiring.

This is the same load-bearing observation as in ge-py: adopting the shared library gives a new application a database inspector, a commit-DAG browser, an undo-stack viewer, a remote-server sync pipeline, and an embedded Python REPL — all already implemented.

The Admin model — database introspection and history

CommitAdminModel is a single black-box QObject that owns the entire admin surface (notifier setup, settings, undo, actions, program, commits, live mode, blobs, inspector). main.py instantiates it once and registers its context properties:

# main.py
commit_admin = CommitAdminModel(mgr, app, context.store,
                                on_reset_database=context.reset)
commit_admin.registerContextProperties(engine)

QML pulls in the corresponding dsviper_components_qml QML files (commit dialogs, inspector, undo viewer…) and binds against the properties exposed by CommitAdminModel. The application has nothing domain-specific to wire — toggling visibility is the only handler it writes.

The Documents panel

DocumentsPanelModel is the QML equivalent of ge-py’s DSCommitDocumentsDialog — it owns abstraction / key / document / navigation state, and main.py exposes it the same way:

documents_panel = DocumentsPanelModel(mgr, commit_mode=True)
documents_panel.registerContextProperties(engine)

ge-qml’s RenderModel wires its inspectKey signal into the documents panel so that “Inspect this vertex” from the canvas focuses the corresponding key in the panel:

render_model.inspectKey.connect(documents_panel.navigateToKey)

Embedded Python scripting — PythonEditorModel

Same pattern as ge-py’s DSCodeEditorDialog, exposed to QML through a single model. main.py builds it with the application’s Context in the script namespace:

from dsviper_components_qml.python_editor_model import PythonEditorModel

scripts_folder = str(Path(__file__).parent / "scripts")
python_editor_model = PythonEditorModel(scripts_folder, namespace_vars={
    "ctx": context,
    "render_model": render_model,
    "_documents_panel": documents_panel,
})
ctx.setContextProperty("pythonEditorModel", python_editor_model)
# ...
python_editor_model.runInitScript()

Scripts execute in the live interpreter and can drive the application through ctx.dispatch("label", lambda m: …) — every script ends up as a single commit in the DAG, replayable and undoable like any other operation.

For the catalogue of model classes and the conventions for instantiating each one, see dsviper-components. The model-agnostic limit case — generic database editors with no DSM model of their own — is walked through in cdbe.

Where to read first

  1. graph_editor/main.py — the wiring spine: Context, the admin / documents / scripting models, the domain bridge models, and the QML engine boot.

  2. graph_editor/model/context.py — the singleton facade and database lifecycle (compare side-by-side with ge-py’s model/context.py).

  3. graph_editor/vertex_model.py — a complete bridge model: Properties, Slots, notifier subscription, TransientNotifier preview, dispatch.

  4. graph_editor/GraphVertexPanel.qml — how QML binds to a bridge model (vertexModel.value, vertexModel.setValue(...)).

  5. graph_editor/Main.qml — menus, layout, and the Admin / Documents / Editor menus assembled from the shared library.

Reference

  • DSM — the language ge-qml’s data model is written in.

  • Kibo — the generator that produces ge/.

  • dsviper — the runtime exercised through Context.store.

  • dsviper-components — the shared widget / QML library the UI is built on.

  • ge-py — the Qt Widgets sibling of this application.