gui: floating drop target, clipboard global shortcut, theming, UI watchdog
Continues the build order past Options/Scheduler/Speed Limiter/Batch/ Grabber/tray. - DropTargetWidget: frameless always-on-top drop target (docs/03-gui-spec.md §5), position persisted, accepts a dropped http(s) URL or link text and opens FileInfoDialog directly (skipping Add URL, since the URL is already known). Shown/hidden from general.showDropTarget, live via event.settings.changed, same pattern MainWindow already used for general.minimizeToTray. - Clipboard, explicit path #2 (docs/06-risks-and-spikes.md R2): GlobalShortcut wraps org.freedesktop.portal.GlobalShortcuts (CreateSession -> BindShortcuts -> Activated), triggering the same Add URL flow. Guarded end-to-end on `if(TARGET Qt6::DBus)` / VELOX_GUI_HAVE_DBUS so a build without the component degrades to "feature skipped," not broken (gui/docs/pkg-qa-requests-m1.md R4). Best-effort by design per the risk doc: fails silent, never advertised. Verified live against the real portal (a real Wayland session, not just offscreen): `CreateSession` refuses every caller with "An app id is required" — reproduced identically via a bare `busctl` call with no Qt involved at all, so this is the portal requiring a sandboxed caller identity, not something fixable from an unconfined process. Recorded as a partial Spike S2 answer in docs/06-risks-and-spikes.md: this explicit path likely doesn't work for Velox as a traditionally-packaged app on stock GNOME, only if/when it ships confined. Also fixed a real leak this verification caught: QDBusInterface's introspection cache reads as a LeakSanitizer leak the first time anything touches D-Bus (tst_rtl went red under ASan) — switched to QDBusMessage::createMethodCall, which needs no introspection. - Theming (docs/03-gui-spec.md §7): gui/resources/qss/{idm-like,dark}.qss, each with a documented palette block up top (QSS itself has no variable syntax), applied by ThemeManager and kept live via QStyleHints::colorSchemeChanged. util/Theme.hpp gives the handful of inline C++ styles (status dot, offline banner, the eleven identical error-label styles across dialogs) named constants instead of a twelfth copy of the same hex. - UiThreadWatchdog: the M1 DoD's 200 ms debug-build watchdog. A background std::thread pings the UI thread every 50 ms via a queued invokeMethod and warns once (not per-poll) if a ping goes unanswered past 200 ms; no QThread, no Qt event loop of its own, so the watchdog itself can never be what blocks the thread it watches. No-op in a release build. Proven both ways in tst_uithreadwatchdog: fires on a genuinely blocked UI thread (synchronous sleep, no processEvents) and stays silent on a responsive one. Full non-conformance suite (55 tests across every lane, `ctest -LE conformance`) passes clean at this point, including the whole gui label under ASan+UBSan. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// Named semantic colours for the handful of places that set an inline style directly
|
||||
// (status dot, offline banner, error labels) rather than through the QSS skin. Lane GUI.
|
||||
//
|
||||
// docs/agents/AGENT-GUI.md build order step 8: "colours in one variables block... no
|
||||
// hard-coded hex scattered through widget code." QSS itself has no variable syntax, so
|
||||
// ThemeManager's stylesheets carry their own documented palette block for everything QSS
|
||||
// covers; these are the few colours C++ sets directly (a connection-state dot, an error
|
||||
// label) because they're driven by application state rather than a widget's style role,
|
||||
// and belong here instead of a fourth copy of the same hex string.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace velox::gui::theme {
|
||||
|
||||
// Status-dot / banner colours. Deliberately the same in light and dark — a red "you're
|
||||
// disconnected" dot needs to stay legible and unambiguous regardless of theme, not
|
||||
// follow it.
|
||||
inline constexpr auto kDanger = "#c0392b"; // disconnected, errors
|
||||
inline constexpr auto kSuccess = "#27ae60"; // connected
|
||||
inline constexpr auto kWarning = "#e67e22"; // reconnecting
|
||||
|
||||
inline constexpr auto kOfflineBannerBg = "#5a3a00";
|
||||
inline constexpr auto kOfflineBannerText = "#ffd9a0";
|
||||
|
||||
/// The inline style every dialog's error label already used identically eleven times
|
||||
/// over, spelled out once.
|
||||
inline QString errorLabelStyle() {
|
||||
return QStringLiteral("color: %1;").arg(QLatin1String(kDanger));
|
||||
}
|
||||
|
||||
} // namespace velox::gui::theme
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "util/ThemeManager.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFile>
|
||||
#include <QLoggingCategory>
|
||||
#include <QStyleHints>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
Q_LOGGING_CATEGORY(lcTheme, "velox.gui.theme")
|
||||
|
||||
QString loadQss(const QString &resourcePath) {
|
||||
QFile f(resourcePath);
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
qCWarning(lcTheme, "could not load %s", qUtf8Printable(resourcePath));
|
||||
return {};
|
||||
}
|
||||
return QString::fromUtf8(f.readAll());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ThemeManager::ThemeManager(QObject *parent) : QObject(parent) {
|
||||
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this,
|
||||
&ThemeManager::onColorSchemeChanged);
|
||||
}
|
||||
|
||||
void ThemeManager::apply() {
|
||||
const bool dark = QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark;
|
||||
const QString qss =
|
||||
loadQss(dark ? QStringLiteral(":/qss/dark.qss") : QStringLiteral(":/qss/idm-like.qss"));
|
||||
if (!qss.isEmpty()) {
|
||||
qApp->setStyleSheet(qss);
|
||||
}
|
||||
}
|
||||
|
||||
void ThemeManager::onColorSchemeChanged() {
|
||||
apply();
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,25 @@
|
||||
// Applies gui/resources/qss/{idm-like,dark}.qss and follows the system light/dark
|
||||
// preference live. Lane GUI. docs/03-gui-spec.md §7.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class ThemeManager : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ThemeManager(QObject *parent = nullptr);
|
||||
|
||||
/// Loads and applies the stylesheet matching the current
|
||||
/// QStyleHints::colorScheme(), and connects to colorSchemeChanged() so a live
|
||||
/// light/dark switch (e.g. GNOME's night-light toggle) re-applies without a restart.
|
||||
void apply();
|
||||
|
||||
private slots:
|
||||
void onColorSchemeChanged();
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "util/UiThreadWatchdog.hpp"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMetaObject>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
Q_LOGGING_CATEGORY(lcWatchdog, "velox.gui.watchdog")
|
||||
|
||||
constexpr int kPollMs = 50;
|
||||
constexpr int kStallThresholdMs = 200;
|
||||
|
||||
qint64 nowMs() {
|
||||
return QDateTime::currentMSecsSinceEpoch();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UiThreadWatchdog::UiThreadWatchdog(QObject *parent) : QObject(parent) {}
|
||||
|
||||
UiThreadWatchdog::~UiThreadWatchdog() {
|
||||
running_.store(false);
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
}
|
||||
|
||||
void UiThreadWatchdog::start() {
|
||||
#ifdef QT_NO_DEBUG
|
||||
return; // release build: no thread, no overhead
|
||||
#endif
|
||||
running_.store(true);
|
||||
worker_ = std::thread([this] { loop(); });
|
||||
}
|
||||
|
||||
void UiThreadWatchdog::loop() {
|
||||
while (running_.load()) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kPollMs));
|
||||
|
||||
if (pingInFlight_.load()) {
|
||||
const qint64 elapsed = nowMs() - pingSentAtMs_.load();
|
||||
if (elapsed >= kStallThresholdMs && !stalledAlready_.exchange(true)) {
|
||||
qCWarning(lcWatchdog,
|
||||
"UI thread has not answered a ping in %lld ms (budget %d ms) — "
|
||||
"something is blocking it",
|
||||
static_cast<long long>(elapsed), kStallThresholdMs);
|
||||
}
|
||||
continue; // don't pile up a second ping while one is still outstanding
|
||||
}
|
||||
|
||||
stalledAlready_.store(false);
|
||||
pingSentAtMs_.store(nowMs());
|
||||
pingInFlight_.store(true);
|
||||
QMetaObject::invokeMethod(this, "ackFromUiThread", Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
|
||||
void UiThreadWatchdog::ackFromUiThread() {
|
||||
pingInFlight_.store(false);
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,48 @@
|
||||
// Debug-build UI-thread watchdog. Lane GUI.
|
||||
//
|
||||
// docs/agents/AGENT-GUI.md M1 DoD: "No blocking call on the UI thread: verified with a
|
||||
// 200 ms watchdog in debug builds." A background std::thread pings the UI thread every
|
||||
// 50 ms via a queued QMetaObject::invokeMethod and checks the previous ping actually got
|
||||
// answered within 200 ms; if not, it logs once (not once per poll — a real stall can last
|
||||
// seconds, and re-warning every 50 ms of it says nothing new). No Qt event loop, no
|
||||
// QThread subclass: the only cross-thread contact is the queued invoke itself and three
|
||||
// atomics, so the watchdog itself can never be what blocks the thread it's watching.
|
||||
//
|
||||
// No-op in a release build (`start()` returns immediately when QT_NO_DEBUG is defined) —
|
||||
// this is a diagnostic, not a feature; it must add zero overhead to what ships.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class UiThreadWatchdog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit UiThreadWatchdog(QObject *parent = nullptr);
|
||||
~UiThreadWatchdog() override;
|
||||
|
||||
/// Call once, from the UI thread, after the event loop exists (i.e. anywhere in
|
||||
/// main() before QApplication::exec()). No-op in a release build.
|
||||
void start();
|
||||
|
||||
public slots:
|
||||
/// Queued-invoked onto the UI thread by the watchdog's own background thread. Not
|
||||
/// meant to be called directly.
|
||||
void ackFromUiThread();
|
||||
|
||||
private:
|
||||
void loop();
|
||||
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> pingInFlight_{false};
|
||||
std::atomic<bool> stalledAlready_{false};
|
||||
std::atomic<qint64> pingSentAtMs_{0};
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
Reference in New Issue
Block a user