Files
create/docs/src/architecture/signal-architecture.md
forbes 87a0af0b0f phase 1: copy Kindred-only files onto upstream/main (FreeCAD 1.2.0-dev)
Wholesale copy of all Kindred Create additions that don't conflict with
upstream FreeCAD code:

- kindred-icons/ (1444 Catppuccin Mocha SVG icon overrides)
- src/Mod/Create/ (Kindred Create workbench)
- src/Gui/ Kindred source files (FileOrigin, OriginManager,
  OriginSelectorWidget, CommandOrigin, BreadcrumbToolBar, EditingContext)
- src/Gui/Icons/ (Kindred branding and silo icons)
- src/Gui/PreferencePacks/KindredCreate/
- src/Gui/Stylesheets/ (KindredCreate.qss, images_dark-light/)
- package/ (rattler-build recipe)
- docs/ (architecture, guides, specifications)
- .gitea/ (CI workflows, issue templates)
- mods/silo, mods/ztools submodules
- .gitmodules (Kindred submodule URLs)
- resources/ (kindred-create.desktop, kindred-create.xml)
- banner-logo-light.png, CONTRIBUTING.md
2026-02-13 14:03:58 -06:00

10 KiB

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):

fastsignals::signal<void(ConnectionState)> signalConnectionStateChanged;

OriginManager (src/Gui/OriginManager.h):

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):

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):

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):

// 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:

fastsignals::signal<void(const std::string&)> signalSomethingHappened;

Use mutable if the signal needs to fire from const methods (as Gui::Document does):

mutable fastsignals::signal<void(int)> signalReadOnlyEvent;

Name signals with the signal prefix by convention.

Emitting

Call the signal like a function:

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:

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

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.

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:

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:

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:

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:
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:

std::vector<fastsignals::scoped_connection> conns;
conns.push_back(std::move(conn));  // OK

See also