Files
logos-basecamp/src/ShortcutBridge.cpp
T
Dario Gabriel Lipicar 9b8cf6e47e feat(shell): ship main_ui as a plugin that links no logos runtime
Measured on the built artefact: main_ui.dylib DEFINES zero and IMPORTS zero of
TokenManager, StoreRegistry, LogosAPI, LogosAPIClient and logos_core_*, out of
3410 symbols read. It links Qt and nothing else from this workspace, which
nix/symbol-gate.nix enforces across the in-process image set.

Getting there needed the last non-Qt types off the boundary: the model
properties cross as QAbstractItemModel*, catalogInstallStageChanged carries an
int rather than InstallStage::Value, and the two prebuilt AppsFilterProxy
instances are declared in QML instead of owned by MainUIBackend. That last one
removes a real inversion -- PackageCoordinator called setRequiredPackages() on
a proxy the host held a pointer to; it now emits requiredPackagesResolved() and
QML binds to the republished property.

Window resolves the plugin, qobject_casts it to IShellView, checks
hostAbiVersion() against IShellHost_abi, and calls createShell(IShellHost*).
No error-label fallback widget: that degraded to something that looked like a
working app with an empty window.

Filter proxies read role constants off host-side models and InstallEnums is
used by nine host files, so app/interfaces/ gains the contract headers both
sides compile against -- the models inherit the role structs, leaving every
AppsModel::NameRole call site unchanged. The plugin's include path is
app/interfaces only, so including a host header does not compile.

Four things only running it finds:
  * Logos::DesignSystem may be linked by exactly ONE image -- both linked it
    and the app aborted with "Cannot add multiple registrations for
    Logos.Icons"; QML module registration is process-global
  * qmltyperegistrar emits no #include for a SOURCES header given as an
    absolute path outside the project
  * each qt_add_qml_module is its own target and inherits no include dirs
  * AUTOMOC pairs header<->cpp by same-basename-same-DIRECTORY, which the
    split breaks

tst_AppManagerView.qml grows five tests for the QML binding, checked with a
negative control: breaking one assertion fails qml-tests, so they run.
2026-08-22 16:42:13 -03:00

206 lines
7.2 KiB
C++

#include "ShortcutBridge.h"
#include <QDebug>
#include <QKeySequence>
#include <QMetaObject>
#include <QQuickItem>
#include <QQuickWidget>
#include <QShortcut>
#include <QStackedWidget>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QWidget>
namespace {
// Substring match — QQuickShortcut lives in QtQuick and its moc'd class
// name is stable across Qt 6.x variants.
constexpr auto kShortcutClass = "QQuickShortcut";
} // namespace
ShortcutBridge::ShortcutBridge(QWidget* host,
QStackedWidget* stack,
DockPaneProvider dockProvider)
: QObject(host)
, m_host(host)
, m_stack(stack)
, m_dockProvider(std::move(dockProvider))
{
if (m_stack) {
connect(m_stack, &QStackedWidget::currentChanged,
this, &ShortcutBridge::rebindDeferred);
}
rebindDeferred();
}
void ShortcutBridge::rebindDeferred()
{
// Queued: currentChanged often fires mid-swap and the new pane's
// QML may still be Loading — let the frame settle before scanning.
QMetaObject::invokeMethod(this, &ShortcutBridge::rebind,
Qt::QueuedConnection);
}
void ShortcutBridge::rebind()
{
clearMirrors();
QWidget* current = m_stack ? m_stack->currentWidget() : nullptr;
if (!current) return;
if (auto* qw = qobject_cast<QQuickWidget*>(current)) {
scanPane(qw);
return;
}
// Current widget hosts a QQuickWidget rather than being one (e.g.
// WorkspaceArea → active dock). Ask the host for it.
if (m_dockProvider) {
if (QQuickWidget* dockPane = m_dockProvider())
scanPane(dockPane);
}
}
bool ShortcutBridge::scanPane(QQuickWidget* pane)
{
if (!pane) return false;
// QML still loading — retry when Ready, otherwise findChildren would
// return empty and we'd silently miss all its shortcuts.
if (pane->status() != QQuickWidget::Ready) {
m_transientConns.append(
connect(pane, &QQuickWidget::statusChanged,
this, [this](QQuickWidget::Status s) {
if (s == QQuickWidget::Ready) rebindDeferred();
}));
return false;
}
QQuickItem* root = pane->rootObject();
if (!root) return false;
// findChildren traverses both QQuickItem child items and their
// attached QObject children — where QML `Shortcut { }` lives.
for (QObject* obj : root->findChildren<QObject*>()) {
if (!obj) continue;
const QString cls = QString::fromUtf8(obj->metaObject()->className());
if (!cls.contains(QLatin1String(kShortcutClass))) continue;
mirrorOneShortcut(obj);
}
if (!m_mirrors.isEmpty()) {
qDebug() << "ShortcutBridge: bound" << m_mirrors.size()
<< "QML shortcut(s) on pane"
<< pane->metaObject()->className();
}
return true;
}
void ShortcutBridge::mirrorOneShortcut(QObject* obj)
{
// Guards against a future Qt class whose name coincidentally
// contains "QQuickShortcut" but isn't one.
if (obj->metaObject()->indexOfSignal("activated()") < 0) return;
// Widget-scoped shortcuts are deliberately item-local — don't
// silently promote them to pane-wide via a mirror.
const int contextVal = obj->property("context").toInt();
if (contextVal == Qt::WidgetShortcut ||
contextVal == Qt::WidgetWithChildrenShortcut) {
return;
}
// QML shortcuts declare either `sequence` (single) or `sequences`
// (list of alternatives) — mirror each so any of them fires.
QVariantList sequences;
const QVariant sequencesVar = obj->property("sequences");
if (sequencesVar.isValid() && sequencesVar.canConvert<QVariantList>()) {
sequences = sequencesVar.toList();
}
if (sequences.isEmpty()) {
const QVariant seqVar = obj->property("sequence");
if (seqVar.isValid()) sequences.append(seqVar);
}
for (const QVariant& seqVar : sequences) {
const QKeySequence seq(seqVar.toString());
if (seq.isEmpty()) continue;
// ApplicationShortcut: we've already scoped by "this pane is
// current" via mirror lifetime, so widget/window scoping would
// just re-introduce the offscreen-window problem.
auto* mirror = new QShortcut(seq, m_host);
mirror->setContext(Qt::ApplicationShortcut);
mirror->setEnabled(obj->property("enabled").toBool());
// UniqueConnection makes this loop safe on two axes:
// * a QML Shortcut can declare multiple sequences (each mirrored)
// — the enabledChanged→slot wire should still be one connection
// per QML shortcut, not one per sequence.
// * rebind() clears the C++ mirrors but leaves the QML shortcuts
// alive (they belong to the pane's QML tree). The next scan
// would re-connect the same signal, and without this flag
// onQmlShortcutEnabledChanged() would fire N times per change
// after N pane switches.
connect(obj, SIGNAL(enabledChanged()),
this, SLOT(onQmlShortcutEnabledChanged()),
Qt::UniqueConnection);
m_mirrorToQml.insert(mirror, obj);
m_qmlToMirrors.insert(obj, QPointer<QShortcut>(mirror));
// Three-way wiring covers Qt's ambiguity cycling: on platforms
// where the QML shortcut also matches, Qt alternates
// `activatedAmbiguously` between the two sides — hooking both
// ends means the handler always runs on the first press.
// String signals: QQuickShortcut has no public header.
connect(mirror, SIGNAL(activated()),
this, SLOT(onWiredShortcut()));
connect(mirror, SIGNAL(activatedAmbiguously()),
this, SLOT(onWiredShortcut()));
connect(obj, SIGNAL(activatedAmbiguously()),
this, SLOT(onWiredShortcut()));
m_mirrors.append(mirror);
}
}
void ShortcutBridge::onQmlShortcutEnabledChanged()
{
QObject* qml = sender();
if (!qml) return;
const bool enabled = qml->property("enabled").toBool();
for (auto it = m_qmlToMirrors.constFind(qml);
it != m_qmlToMirrors.constEnd() && it.key() == qml; ++it) {
if (QShortcut* m = it.value()) m->setEnabled(enabled);
}
}
void ShortcutBridge::clearMirrors()
{
for (QPointer<QShortcut> sc : m_mirrors) {
if (sc) sc->deleteLater();
}
m_mirrors.clear();
m_mirrorToQml.clear();
m_qmlToMirrors.clear();
// Drop pending statusChanged hooks so the old pane's late-Ready
// signal doesn't retrigger a rebind after we've moved on.
for (const QMetaObject::Connection& c : m_transientConns) disconnect(c);
m_transientConns.clear();
}
void ShortcutBridge::onWiredShortcut()
{
QObject* src = sender();
if (!src) return;
// Mirror → its QML target; QML shortcut itself → use directly.
QPointer<QObject> qml = m_mirrorToQml.value(src);
if (!qml) qml = src;
if (!qml) return;
// Check enabled at fire time so `enabled: someBinding` still works.
if (!qml->property("enabled").toBool()) return;
QMetaObject::invokeMethod(qml.data(), "activated",
Qt::DirectConnection);
}