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 repository —
digital-substrate/ge-qml.Entry point —
graph_editor.py(shim that runsgraph_editor/main.py).Dependencies —
PySide6(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 |
|
Code-gen |
|
|
Runtime |
|
|
Shared QML |
|
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
notifysignals — QML bindings track them automatically;exposes user actions as
@Slotmethods — QML calls them by name;subscribes to
CommitStorenotifications (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 channel — TransientNotifier — 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 |
|---|---|
|
Concept keys ( |
|
Typed accessors ( |
|
Embedded DSM definitions, loaded into the database |
|
Type registry helpers |
|
Field paths |
|
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¶
graph_editor/main.py— the wiring spine:Context, the admin / documents / scripting models, the domain bridge models, and the QML engine boot.graph_editor/model/context.py— the singleton facade and database lifecycle (compare side-by-side with ge-py’smodel/context.py).graph_editor/vertex_model.py— a complete bridge model: Properties, Slots, notifier subscription,TransientNotifierpreview, dispatch.graph_editor/GraphVertexPanel.qml— how QML binds to a bridge model (vertexModel.value,vertexModel.setValue(...)).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.