Making Qt collaborative using CRDTs

David Brochart
13 min read6 days ago

--

Introduction

As I embarked on creating a collaborative Qt application, I began taking notes from the outset. While I’m well-versed in CRDTs, I’m new to Qt development. I believe these notes will be valuable as they will document my approach in a straightforward and, hopefully, clear and understandable manner.
I hope that you are more familiar with Qt than I am, but if you are not then we are in the same boat. All we need to know is that Qt is a framework for building GUI applications.
Regarding CRDTs, they stand for “Conflict-Free Replicated Data Type”. These are data structures that can be shared among multiple clients, modified concurrently, and synchronized remotely. For an excellent interactive introduction to CRDTs, you can check out this resource.
All the code presented in this article can be found in the pyqt-crdt GitHub repository. The examples are written in Python, and they make use of PySide6 for the Qt application and PyCRDT for the CRDT implementation.

The Hello World application

Let’s start with the official Create your first Qt Application documentation. The application just consists of a button at the bottom, and a greeting message in the middle of the window. When you click the button, the greeting message changes. The code is pretty simple:

import sys
import random
from PySide6 import QtCore, QtWidgets

class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()

self.hello = ["Hallo Welt", "Hei maailma", "Hola Mundo", "Привет мир"]

self.button = QtWidgets.QPushButton("Click me!")
self.text = QtWidgets.QLabel(
"Hello World",
alignment=QtCore.Qt.AlignCenter,
)

self.layout = QtWidgets.QVBoxLayout(self)
self.layout.addWidget(self.text)
self.layout.addWidget(self.button)

self.button.clicked.connect(self.magic)

@QtCore.Slot()
def magic(self):
self.text.setText(random.choice(self.hello))

if __name__ == "__main__":
app = QtWidgets.QApplication([])

widget = MyWidget()
widget.resize(800, 600)
widget.show()

sys.exit(app.exec())

Our goal is to have multiple users running the application on their own computers, connected to each other, and seeing each other’s changes in real-time. For this application, when a user clicks a button, the greeting message updates instantly on everyone’s application. Simple, right?
Well, not quite. Consider what happens if two users click the button simultaneously. Initially, each user sees the message change on their own computer — perhaps one sees “Hallo Welt” and the other “Hola Mundo”. However, collaborative work means everyone should see the same thing eventually. In this case, the content is a string that can be replaced with a new one. Ultimately, everyone must see the same string on their screen.
So what is happening? The new string from user A travels on the wire and reaches user B. Same on the other direction, the new string from user B reaches user A. Since they are different, there is clearly a conflict. How do we choose which string to keep? And how do we communicate this decision to every user?
That is where the magic of CRDTs comes into play. They take care of conflicts for us, so that we don’t have to worry about them. We can make changes locally, as if we owned the data. In reality the data is shared, but each client has a copy on which it can operate optimistically: if a conflict happens, the data will eventually converge towards a common state. It means that we have to observe the data and react accordingly, but from a user’s perspective, that’s a big relief: all the complexity of handling remote data is externalized, and we can just work as if data was local.

Making the client collaborative

We first need to define our shared document. That is a top-level structure that has so-called “root types” (more on that in the documentation). Here we only want some collaborative text that will represent the greeting message, so that would be:

from pycrdt import Doc, Text

class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()

self.greeting = Text()
self.doc = Doc({"greeting": self.greeting})

Next, let’s replace the direct assignment to the text that happens every time we click on the button:

@QtCore.Slot()
def magic(self):
self.text.setText(random.choice(self.hello))

with an assignment to our shared text:

@QtCore.Slot()
def magic(self):
with self.doc.transaction():
self.greeting.clear()
self.greeting += random.choice(self.hello)

See how we use a transaction context manager. This ensures our changes will be done atomically, not separately. Otherwise that could lead to issues if concurrent changes are interleaved.
Eventually, we need to keep the setText() call somewhere to update our view, but it has to be done indirectly through changes of the shared model. For that, we need to observe the changes, and this can be done by registering a callback:

class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()

self.greeting = Text()
self.doc = Doc({"greeting": greeting})
self.hello = ["Hallo Welt", "Hei maailma", "Hola Mundo", "Привет мир"]

self.button = QtWidgets.QPushButton("Click me!")
self.text = QtWidgets.QLabel(
"Hello World",
alignment=QtCore.Qt.AlignCenter,
)

def callback(event):
self.text.setText(str(self.greeting))

self.greeting.observe(callback)

And that’s it, these are all the changes needed to make our client use collaborative data! We can already run the application as-is, and it should behave as the original one. Not very useful yet, but wait for it.

Connecting the clients

Up to now our client application only works on local data. The next step is connecting our shared document to the outside world so that changes can be received from other clients. That is done through a so-called provider. There are providers for various transport layers. Here we will use a WebSocket provider, and our clients will be connected to a web server.
Usually providers are asynchronous, but for this particular example we will use a synchronous one. The reason is that Qt has its own event loop, and to run an asynchronous provider we would need to somehow integrate the Qt event loop and the asyncio event loop. There are solutions for that, but for simplicity we will just use a timer that will process messages periodically. Not very efficient, but good enough here.

from httpx_ws import connect_ws
from pyqt_crdt import WebsocketProvider

if __name__ == "__main__":
app = QtWidgets.QApplication([])

widget = MyWidget()
widget.resize(800, 600)
widget.show()

with connect_ws("http://localhost:1234/my_room") as websocket:
websocket_provider = WebsocketProvider(widget.doc, websocket)
websocket_provider.start()

timer = QtCore.QTimer()
timer.timeout.connect(websocket_provider.run)
timer.start()

sys.exit(app.exec())

Note that we append a room name to the WebSocket URL. The room is the place where all clients collaborate, a bit like a chat room. Here all our clients will connect to the room called my_room, but if other clients connect to a room called my_other_room, they will collaborate among themselves, not with the clients in room my_room.

The server

That is the last part to make the whole system work. In this centralized architecture, the server receives changes from every client and broadcasts them to every other client. Here is what it looks like:

import asyncio
from hypercorn import Config
from hypercorn.asyncio import serve
from pycrdt_websocket import ASGIServer, WebsocketServer

websocket_server = WebsocketServer()
app = ASGIServer(websocket_server)

async def main():
websocket_server = WebsocketServer()
app = ASGIServer(websocket_server)
config = Config()
config.bind = ["localhost:1234"]
async with websocket_server:
await serve(app, config, mode="asgi")

asyncio.run(main())

This code can be run as-is in a separate process, let’s save it in server.py.

Putting it all together

Let’s open a terminal and run our server:

python server.py

In another terminal, let’s run a client application:

python client.py

And let’s run a second client application yet in another terminal with this last command.
When you click the button in one window, you should see the message changing in both windows. Of course everything is running locally on the same machine, but nothing prevents deploying the server on the Internet and accessing it from two different machines.

The collaborative “hello world” application.

A slider widget

This application will show a slider, a label which shows the current slider’s value, and a button to reset the value:

import sys

from httpx_ws import connect_ws
from pycrdt import Doc, Map
from pyqt_crdt import WebsocketProvider
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QWidget,
QSlider,
QPushButton,
QLabel,
QApplication,
)


class MyWidget(QWidget):

def __init__(self):
super().__init__()
self.resize(320, 150)

self.slider = QSlider(Qt.Horizontal, self)
self.slider.setGeometry(10, 10, 300, 40)
self.slider.valueChanged.connect(self.slider_value_changed)

self.button = QPushButton("Reset", self)
self.button.setGeometry(10, 50, 100, 35)
self.button.clicked.connect(self.button_reset_value)

self.label = QLabel(self)
self.label.setGeometry(250, 50, 50, 35)

self.map = Map()
self.doc = Doc({"map": self.map})
self.map.observe(self.shared_value_changed)


def shared_value_changed(self, event):
value = self.map["value"]
self.slider.setValue(value)
self.label.setText(str(int(value)))

def slider_value_changed(self, value):
try:
self.map["value"] = value
except Exception:
pass

def button_reset_value(self):
self.map["value"] = 0


if __name__ == "__main__":
app = QApplication(sys.argv)

widget = MyWidget()
widget.show()

with connect_ws("http://localhost:1234/my_room") as websocket:
websocket_provider = WebsocketProvider(widget.doc, websocket)
websocket_provider.start()

timer = QTimer()
timer.timeout.connect(websocket_provider.run)
timer.start()

sys.exit(app.exec())

We want the slider to be controllable by us (the user moving the slider with the mouse), by the reset button, and by other users. As we saw in the previous example, we cannot manipulate the values of our widgets directly, instead we must go through the shared model and react to its changes. It means that:
- the slider_value_changed() method that is called when moving the slider from the UI updates the shared value.
- the button_reset_value() method that is called when resetting the value from the button also updates the shared value.
- we observe the shared value changes in the shared_value_changed() method, and update the slider and label views with the new value.

You may have noticed that there is a recursion here: when we move the slider, the shared value is changed (in slider_value_changed), then we react to that change by setting the slider value (in shared_value_changed), which calls slider_value_changed again, and so on. In order to break this recursion, we would have to know if the (local) user is the one responsible for changing the slider value, and if so, not react to that change. And if the change is coming from a remote user, always react. This can be achieved using the concept of “origin”, which basically allows to propagate the information about who makes a change. If we know we originally made a change through our UI, then we don’t want to update the UI again when we see that change. Another way of breaking the recursion could be to take advantage of the fact that it is not possible to make changes to a shared model in a callback that was called by a shared model change: this would raise an error because it would lead to an infinite recursion. So in slider_value_changed we just need to try and change the shared slider value in a try/except block.
One more thing we can notice, is that there is no initial value to the slider’s value. We could have set the shared value to 0 when initializing the widget, but that would have had the side effect of resetting every user’s slider whenever a new user launches the application. Here a new user just sees the current shared value at startup. This is something to keep in mind especially for container shared values, such as arrays. For instance, if each new user added an item to an array at startup, that would introduce a dependency on the number of users into our shared document.

A synchronized Qt slider using CRDTs.

A complex shared document

The last example illustrates how a complex data structure can be handled by a shared document. A good example of that can be found in a collaborative Jupyter notebook, which holds the cells in an array, each one consisting of a shared text. That allows users to collaborate on the notebook structure itself (for instance moving cells) as well as on individual cells (typing in the same cell at the same time).
We are not going to recreate a full notebook here, but just a document where users can add cells, type into the cells, and delete cells. It seems natural to represent cells as a shared array, since they are ordered, and each cell must at least consist of a shared text. But we may want to add more information to a cell, like its ID, so let’s represent a cell as a shared map that holds its content and its ID.
Our UI will consist of a vertical layout with a button to add a cell at the top, and cells appended underneath. Here is how it looks like:

from pycrdt import Array, Doc, Map, Text

class Notebook(QWidget):
def __init__(self):
super().__init__()

self.doc = Doc()
self.cells = self.doc.get("cells", type=Array)

self.add_cell_button = QPushButton("Add cell")
self.add_cell_button.clicked.connect(self.add_cell)

self.layout = QVBoxLayout()
self.layout.addWidget(self.add_cell_button)
self.setLayout(self.layout)

self.cells.observe_deep(self.cells_changed)

def add_cell(self):
cell = Map({
"text": Text(),
"id": uuid4().hex,
})
self.cells.append(cell)

As mentioned earlier, the cell array must initially be empty in order for the document to be unaffected by every user connection. If we created an empty cell during the initialization, each new user would add a cell upon connection, which we don’t want.
Note that we observe changes to the shared cells using a new method observe_deep. This will not only observe changes to the cell array, but also to the nested shared data that it contains. This way we will react to changes on the cell structure, like deletion of cells, and also on their content, when users type into the cell input. Let’s see how the cells_changed method look like:

def cells_changed(self, events):
for event in events:
if event.path:
idx, key = event.path
cell = self.layout.itemAt(idx + 1)
input_widget = cell.itemAt(0).widget()
cursor_position = input_widget.cursorPosition()
input_widget.setText(str(event.target))
input_widget.setCursorPosition(cursor_position)
else:
row = 1
for delta in event.delta:
row += delta.get("retain", 0)
delete = delta.get("delete")
if delete is not None:
layout = self.layout.takeAt(row)
while True:
item = layout.takeAt(0)
if item is None:
return
item.widget().deleteLater()
for cell in delta.get("insert", []):
cell_layout = QHBoxLayout()
input_widget = QLineEdit()
shared_text = cell["text"]
input_widget.textEdited.connect(
partial(self.my_text_edited, shared_text)
)
input_widget.setText(str(shared_text))
cell_layout.addWidget(input_widget)
delete_button = QPushButton("Delete cell")
delete_button.clicked.connect(
partial(self.delete_cell, cell["id"])
)
cell_layout.addWidget(delete_button)
self.layout.addLayout(cell_layout, row)
row += 1

First, note that it accepts a list of events. Each event in this list has a path property which is a sequence pointing to the data that has changed. If the text of a cell has changed, this sequence will consist of the cell index in the Array, then the key of the Map (here, "text"). On every text change, we just need to set the text of our UI to the new shared text value. We could do a better job at handling only the diff of the shared text, but setting the text as a whole will be good enough for this example.
If the event path is empty, this means the change is about the top-level structure of the cells, which is the shared array. Here we will have to handle the change diff, represented in the delta property of the event. This property is a dictionary that can have the following keys:
- "retain": the corresponding value indicates how many items should be left untouched before doing the following operations. We can use this value to keep track of the current index in the array.
- "delete": the corresponding value indicates how many items should be deleted at the current index.
- "insert": the corresponding value consists of a list of items to insert at the current index.

For each inserted cell, we create a QLineEdit that will be the UI for the cell text, which we connect to a my_text_edited() method. This method will update the shared text at each user keystroke. We also add a button that will allow to delete the cell, and connect it to a delete_cell() method that will delete the corresponding cell in the shared array, using its ID:

def delete_cell(self, cell_id):
for idx, cell in enumerate(self.cells):
if cell["id"] == cell_id:
del self.cells[idx]
return

def my_text_edited(self, shared_text, new_text):
with shared_text.doc.transaction():
shared_text.clear()
shared_text += new_text

We are almost done, the only remaining thing is to connect our shared document with a WebSocket provider. This time we will use an async provider, which is more efficient than the polling mechanism we were using in the previous examples. But the difficulty is to somehow integrate the Qt event loop and the asyncio event loop, since they must run at the same time. There is a QtAsyncio event loop that can be used to run an asyncio task, but it is not a complete event loop at the time of writing this post, and it won’t work in our case. Instead we will use the approach described in the async example, and in particular the guest mode from the Trio event loop, that we adapt to asyncio using the aioguest project. Our notebook widget will have a start() method that will use an async WebSocket provider, and we will reuse the AsyncHelper class from Qt's async example:

    async def start(self):
room_name = "my_room"
async with (
aconnect_ws(f"http://localhost:1234/{room_name}") as websocket,
WebsocketProvider(
self.doc,
HttpxWebsocket(websocket, room_name),
),
):
await anyio.Event().wait()


if __name__ == "__main__":
app = QApplication(sys.argv)

widget = Notebook()
AsyncHelper(widget.start)
widget.show()

sys.exit(app.exec())
A collaborative notebook-like document.

Conclusion

I hope that these examples show how easy it is to make a Qt application collaborative. This of course can be applied to any other UI framework. JupyterLab is a great example of a web framework that has been updated to become collaborative. It uses Yjs in the frontend, which is a very popular JavaScript CRDT library. In fact, pycrdt is a Python binding to the Yrs library, which is itself the Rust port of Yjs.
Things we didn't cover here include peer-to-peer communication, for instance using a WebRTC provider. This can be useful when there is no central architecture, as in JupyterLite which runs entirely in the browser, without a server. Other synchronization systems could be implemented, like sharing a URL to someone and inviting them to collaborate, that could make use of authentication and permissions.
As the main take-away, I would say that CRDTs are in a way very developer-friendly: they allow us to think of our application as a local application, and get it to synchronize with the outside world basically for free. CRDTs are transport-agnostic, so you are not tied to any particular technology for remote access. And in a way, this decoupling is what makes CRDTs a kind of universal "write once, connect anywhere" solution. Or don't connect at all: the app can still work offline, and synchronize when going back online. This is the whole philosophy behind the local-first software movement.

Acknowledgements

Funding

This work was supported by the Centre de Données de la Physique des Plasmas (CDPP), the French national data centre for natural plasmas of the solar system, as part of their initiative to enhance collaborative capabilities in space science software. The integration of PyCRDT with PySide6 was developed under this sponsorship to enable real-time collaboration features in SciQLop. Learn more about CDPP’s mission at https://cdpp.irap.omp.eu.

About the author

David Brochart is a technical director at QuantStack, a company focusing on scientific computing and open-source, and contributing to the Jupyter ecosystem among other projects. David created PyCRDT with the JupyterLab project in mind, to facilitate real-time collaboration, but the project remains independent of the Jupyter stack.

--

--

David Brochart
David Brochart

Written by David Brochart

Spatial hydrology. Python. Often found on a bike.

No responses yet