mirror of
https://github.com/logos-co/logos-basecamp.git
synced 2026-08-27 14:51:07 +00:00
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.
206 lines
7.2 KiB
C++
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);
|
|
}
|