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:
2026-09-12 21:30:13 +04:00
co-authored by Claude Sonnet 5
parent 1fd2e0a0db
commit c2eef96175
23 changed files with 1101 additions and 26 deletions
+33
View File
@@ -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
+42
View File
@@ -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
+25
View File
@@ -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
+66
View File
@@ -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
+48
View File
@@ -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