docs(arch): signal architecture — fastsignals vs Qt signals
Explains the dual-signal system: fastsignals for domain events (origin lifecycle, document changes, application state) and Qt signals for UI framework integration. Covers API, connection patterns (scoped_connection RAII, DocumentObserver, temporary blocking), thread safety model, performance, and common mistakes. Closes #137
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
- [Overview](./architecture/overview.md)
|
||||
- [Python as Source of Truth](./architecture/python-source-of-truth.md)
|
||||
- [Silo Server](./architecture/silo-server.md)
|
||||
- [Signal Architecture](./architecture/signal-architecture.md)
|
||||
- [OndselSolver](./architecture/ondsel-solver.md)
|
||||
|
||||
# Development
|
||||
|
||||
274
docs/src/architecture/signal-architecture.md
Normal file
274
docs/src/architecture/signal-architecture.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Signal Architecture
|
||||
|
||||
Kindred Create uses two signal systems side by side: **fastsignals** for domain and application events, and **Qt signals** for UI framework integration. This page explains why both exist, where each is used, and the patterns for working with them.
|
||||
|
||||
## Why two signal systems
|
||||
|
||||
Qt signals require the `Q_OBJECT` macro, MOC preprocessing, and a `QObject` base class. This makes them the right tool for widget-to-widget communication but a poor fit for domain classes like `FileOrigin`, `OriginManager`, and `App::Document` that are not `QObject` subclasses and should not depend on the Qt meta-object system.
|
||||
|
||||
fastsignals is a header-only C++ library that provides type-safe signals with lambda support, RAII connection management, and no build-tool preprocessing. It lives at `src/3rdParty/FastSignals/` and is linked into the Gui module via CMake.
|
||||
|
||||
| | Qt signals | fastsignals |
|
||||
|---|-----------|-------------|
|
||||
| **Requires** | `Q_OBJECT`, MOC, `QObject` base | Nothing (header-only) |
|
||||
| **Dispatch** | Event loop (can be queued) | Synchronous, immediate |
|
||||
| **Connection** | `QObject::connect()` | `signal.connect(lambda)` |
|
||||
| **Lifetime** | Tied to `QObject` parent-child tree | `scoped_connection` RAII |
|
||||
| **Thread model** | Can queue across threads | Fires on emitter's thread |
|
||||
| **Slot types** | Slots, lambdas, `std::function` | Lambdas, `std::function`, function pointers |
|
||||
|
||||
## Where each is used
|
||||
|
||||
### fastsignals — domain events
|
||||
|
||||
These are events about application state that multiple independent listeners may care about. The emitting class is not a `QObject`.
|
||||
|
||||
**FileOrigin** (`src/Gui/FileOrigin.h`):
|
||||
|
||||
```cpp
|
||||
fastsignals::signal<void(ConnectionState)> signalConnectionStateChanged;
|
||||
```
|
||||
|
||||
**OriginManager** (`src/Gui/OriginManager.h`):
|
||||
|
||||
```cpp
|
||||
fastsignals::signal<void(const std::string&)> signalOriginRegistered;
|
||||
fastsignals::signal<void(const std::string&)> signalOriginUnregistered;
|
||||
fastsignals::signal<void(const std::string&)> signalCurrentOriginChanged;
|
||||
fastsignals::signal<void(App::Document*, const std::string&)> signalDocumentOriginChanged;
|
||||
```
|
||||
|
||||
**Gui::Document** (`src/Gui/Document.h`):
|
||||
|
||||
```cpp
|
||||
mutable fastsignals::signal<void(const ViewProviderDocumentObject&)> signalNewObject;
|
||||
mutable fastsignals::signal<void(const ViewProviderDocumentObject&)> signalDeletedObject;
|
||||
mutable fastsignals::signal<void(const ViewProviderDocumentObject&,
|
||||
const App::Property&)> signalChangedObject;
|
||||
// ~9 more document-level signals
|
||||
```
|
||||
|
||||
**Gui::Application** (`src/Gui/Application.h`):
|
||||
|
||||
```cpp
|
||||
fastsignals::signal<void(const Gui::Document&, bool)> signalNewDocument;
|
||||
fastsignals::signal<void(const Gui::Document&)> signalDeleteDocument;
|
||||
fastsignals::signal<void(const Gui::ViewProvider&)> signalNewObject;
|
||||
// ~7 more application-level signals
|
||||
```
|
||||
|
||||
### Qt signals — UI interaction
|
||||
|
||||
These are events between Qt widgets, actions, and framework components where `QObject` is already the base class and the Qt event loop handles dispatch.
|
||||
|
||||
**OriginSelectorWidget** (a `QToolButton` subclass):
|
||||
|
||||
```cpp
|
||||
// QActionGroup::triggered → onOriginActionTriggered
|
||||
connect(m_originActions, &QActionGroup::triggered,
|
||||
this, &OriginSelectorWidget::onOriginActionTriggered);
|
||||
|
||||
// QAction::triggered → onManageOriginsClicked
|
||||
connect(m_manageAction, &QAction::triggered,
|
||||
this, &OriginSelectorWidget::onManageOriginsClicked);
|
||||
```
|
||||
|
||||
### The boundary
|
||||
|
||||
The pattern is consistent: **fastsignals for observable state changes in domain classes, Qt signals for UI framework plumbing**. A typical flow crosses the boundary once:
|
||||
|
||||
```
|
||||
OriginManager emits signalCurrentOriginChanged (fastsignals)
|
||||
→ OriginSelectorWidget::onCurrentOriginChanged (lambda listener)
|
||||
→ updates QToolButton text/icon (Qt API calls)
|
||||
```
|
||||
|
||||
## fastsignals API
|
||||
|
||||
### Declaring a signal
|
||||
|
||||
Signals are public member variables with a template signature:
|
||||
|
||||
```cpp
|
||||
fastsignals::signal<void(const std::string&)> signalSomethingHappened;
|
||||
```
|
||||
|
||||
Use `mutable` if the signal needs to fire from `const` methods (as `Gui::Document` does):
|
||||
|
||||
```cpp
|
||||
mutable fastsignals::signal<void(int)> signalReadOnlyEvent;
|
||||
```
|
||||
|
||||
Name signals with the `signal` prefix by convention.
|
||||
|
||||
### Emitting
|
||||
|
||||
Call the signal like a function:
|
||||
|
||||
```cpp
|
||||
signalSomethingHappened("hello");
|
||||
```
|
||||
|
||||
All connected slots execute **synchronously**, in connection order, before the call returns. There is no event loop queuing.
|
||||
|
||||
### Connecting
|
||||
|
||||
`signal.connect()` accepts any callable and returns a connection object:
|
||||
|
||||
```cpp
|
||||
auto conn = mySignal.connect([](const std::string& s) {
|
||||
Base::Console().log("Got: %s\n", s.c_str());
|
||||
});
|
||||
```
|
||||
|
||||
### Connection types
|
||||
|
||||
| Type | RAII | Copyable | Use case |
|
||||
|------|------|----------|----------|
|
||||
| `fastsignals::connection` | No | Yes | Long-lived, manually managed |
|
||||
| `fastsignals::scoped_connection` | Yes | No (move only) | Member variable, automatic cleanup |
|
||||
| `fastsignals::advanced_connection` | No | No | Temporary blocking via `shared_connection_block` |
|
||||
|
||||
`scoped_connection` is the standard choice for class members. It disconnects automatically when destroyed.
|
||||
|
||||
### Disconnecting
|
||||
|
||||
```cpp
|
||||
conn.disconnect(); // Explicit
|
||||
// or let scoped_connection destructor handle it
|
||||
```
|
||||
|
||||
## Connection patterns
|
||||
|
||||
### Pattern 1: Scoped member connections
|
||||
|
||||
The most common pattern. Store `scoped_connection` as a class member and connect in the constructor or an `attach()` method.
|
||||
|
||||
```cpp
|
||||
class MyListener {
|
||||
fastsignals::scoped_connection m_conn;
|
||||
|
||||
public:
|
||||
MyListener(OriginManager* mgr)
|
||||
{
|
||||
m_conn = mgr->signalOriginRegistered.connect(
|
||||
[this](const std::string& id) { onRegistered(id); }
|
||||
);
|
||||
}
|
||||
|
||||
// Destructor auto-disconnects via m_conn
|
||||
~MyListener() = default;
|
||||
|
||||
private:
|
||||
void onRegistered(const std::string& id) { /* ... */ }
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Explicit disconnect in destructor
|
||||
|
||||
`OriginSelectorWidget` disconnects explicitly before destruction to prevent any signal delivery during teardown:
|
||||
|
||||
```cpp
|
||||
OriginSelectorWidget::~OriginSelectorWidget()
|
||||
{
|
||||
disconnectSignals(); // m_conn*.disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
This is defensive — `scoped_connection` would disconnect on its own, but explicit disconnection avoids edge cases where a signal fires between member destruction order.
|
||||
|
||||
### Pattern 3: DocumentObserver base class
|
||||
|
||||
`Gui::DocumentObserver` wraps ~10 document signals behind virtual methods:
|
||||
|
||||
```cpp
|
||||
class DocumentObserver {
|
||||
using Connection = fastsignals::scoped_connection;
|
||||
Connection connectDocumentCreatedObject;
|
||||
Connection connectDocumentDeletedObject;
|
||||
// ...
|
||||
|
||||
void attachDocument(Document* doc); // connects all
|
||||
void detachDocument(); // disconnects all
|
||||
};
|
||||
```
|
||||
|
||||
Subclasses override `slotCreatedObject()`, `slotDeletedObject()`, etc. This pattern avoids repeating connection boilerplate in every document listener.
|
||||
|
||||
### Pattern 4: Temporary blocking
|
||||
|
||||
`advanced_connection` supports blocking a slot without disconnecting:
|
||||
|
||||
```cpp
|
||||
auto conn = signal.connect(handler, fastsignals::advanced_tag());
|
||||
fastsignals::shared_connection_block block(conn, true); // blocked
|
||||
|
||||
signal(); // handler does NOT execute
|
||||
|
||||
block.unblock();
|
||||
signal(); // handler executes
|
||||
```
|
||||
|
||||
Use this to prevent recursive signal handling.
|
||||
|
||||
## Thread safety
|
||||
|
||||
### What is thread-safe
|
||||
|
||||
- **Emitting** a signal from any thread (internal spin mutex protects the slot list).
|
||||
- **Connecting and disconnecting** from any thread, even while slots are executing on another thread.
|
||||
|
||||
### What is not thread-safe
|
||||
|
||||
- **Accessing the same `connection` object** from multiple threads. Protect with your own mutex or keep connection objects thread-local.
|
||||
- **Slot execution context.** Slots run on the emitter's thread. If a fastsignal fires on a background thread and the slot touches Qt widgets, you must marshal to the main thread:
|
||||
|
||||
```cpp
|
||||
mgr->signalOriginRegistered.connect([this](const std::string& id) {
|
||||
QMetaObject::invokeMethod(this, [this, id]() {
|
||||
// Now on the main thread — safe to update UI
|
||||
updateUI(id);
|
||||
}, Qt::QueuedConnection);
|
||||
});
|
||||
```
|
||||
|
||||
In practice, all origin and document signals in Kindred Create fire on the main thread, so this marshalling is not currently needed. It would become necessary if background workers emitted signals.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Emission:** O(n) where n = connected slots. No allocation, no event loop overhead.
|
||||
- **Connection:** O(1) with spin mutex.
|
||||
- **Memory:** Each signal stores a shared pointer to a slot vector. Each `scoped_connection` is ~16 bytes.
|
||||
- fastsignals is rarely a bottleneck. Profile before optimising signal infrastructure.
|
||||
|
||||
## Adding a new signal
|
||||
|
||||
1. Declare the signal as a public member (or `mutable` if emitting from const methods).
|
||||
2. Name it with the `signal` prefix.
|
||||
3. Emit it at the appropriate point in your code.
|
||||
4. Listeners store `scoped_connection` members and connect via lambdas.
|
||||
5. Document the signal's signature and when it fires.
|
||||
|
||||
Do not create a fastsignal for single-listener scenarios — a direct method call is simpler.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
**Dangling `this` capture.** If a lambda captures `this` and the object is destroyed before the connection, the next emission crashes. Always store the connection as a `scoped_connection` member so it disconnects on destruction.
|
||||
|
||||
**Assuming queued dispatch.** fastsignals are synchronous. A slot that blocks will block the emitter. Keep slots fast or offload work to a background thread.
|
||||
|
||||
**Forgetting `mutable`.** If you need to emit from a `const` method, the signal member must be `mutable`. Otherwise the compiler rejects the call.
|
||||
|
||||
**Copying `scoped_connection`.** It is move-only. Use `std::move()` when putting connections into containers:
|
||||
|
||||
```cpp
|
||||
std::vector<fastsignals::scoped_connection> conns;
|
||||
conns.push_back(std::move(conn)); // OK
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [OriginManager](../reference/cpp-origin-manager.md) — signal catalog for origin lifecycle events
|
||||
- [FileOrigin Interface](../reference/cpp-file-origin.md) — `signalConnectionStateChanged`
|
||||
- [OriginSelectorWidget](../reference/cpp-origin-selector-widget.md) — listener patterns in practice
|
||||
- `src/3rdParty/FastSignals/` — library source and headers
|
||||
Reference in New Issue
Block a user