diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 697e8f20da..e4cd2764c9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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 diff --git a/docs/src/architecture/signal-architecture.md b/docs/src/architecture/signal-architecture.md new file mode 100644 index 0000000000..dbc0db4dd0 --- /dev/null +++ b/docs/src/architecture/signal-architecture.md @@ -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 signalConnectionStateChanged; +``` + +**OriginManager** (`src/Gui/OriginManager.h`): + +```cpp +fastsignals::signal signalOriginRegistered; +fastsignals::signal signalOriginUnregistered; +fastsignals::signal signalCurrentOriginChanged; +fastsignals::signal signalDocumentOriginChanged; +``` + +**Gui::Document** (`src/Gui/Document.h`): + +```cpp +mutable fastsignals::signal signalNewObject; +mutable fastsignals::signal signalDeletedObject; +mutable fastsignals::signal signalChangedObject; +// ~9 more document-level signals +``` + +**Gui::Application** (`src/Gui/Application.h`): + +```cpp +fastsignals::signal signalNewDocument; +fastsignals::signal signalDeleteDocument; +fastsignals::signal 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 signalSomethingHappened; +``` + +Use `mutable` if the signal needs to fire from `const` methods (as `Gui::Document` does): + +```cpp +mutable fastsignals::signal 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 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