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
+57 -20
View File
@@ -37,8 +37,14 @@
#include "rpc/RpcClient.hpp"
#include "tray/TrayIcon.hpp"
#include "util/Format.hpp"
#include "util/Theme.hpp"
#include "widgets/DropTargetWidget.hpp"
#include "widgets/ProgressDelegate.hpp"
#ifdef VELOX_GUI_HAVE_DBUS
#include "clipboard/GlobalShortcut.hpp"
#endif
namespace velox::gui {
namespace {
@@ -88,7 +94,9 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
bannerLayout->addWidget(bannerLabel);
bannerLayout->addStretch();
offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
offlineBanner_->setStyleSheet(
QStringLiteral("background: %1; color: %2;")
.arg(QLatin1String(theme::kOfflineBannerBg), QLatin1String(theme::kOfflineBannerText)));
offlineBanner_->setVisible(false);
auto *splitter = new QSplitter(Qt::Horizontal, this);
@@ -111,9 +119,10 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
buildMenus();
buildToolBar();
buildTray();
buildDropTarget();
// --- status bar -----------------------------------------------------------------
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger)));
statusBar()->addPermanentWidget(countsLabel_, 1);
statusBar()->addPermanentWidget(connText_);
statusBar()->addPermanentWidget(connDot_);
@@ -261,6 +270,23 @@ void MainWindow::buildTray() {
trayIcon_->show();
}
void MainWindow::buildDropTarget() {
// Qt::Tool + a parent keeps it grouped with the main window (no separate taskbar
// entry, destroyed when MainWindow is) while still floating independently per spec.
dropTarget_ = new DropTargetWidget(this);
connect(dropTarget_, &DropTargetWidget::urlDropped, this, &MainWindow::onUrlDropped);
connect(dropTarget_, &DropTargetWidget::addUrlRequested, this, &MainWindow::openAddUrlDialog);
#ifdef VELOX_GUI_HAVE_DBUS
// docs/06-risks-and-spikes.md R2, explicit path #2. Best-effort: requestBinding() is
// silent and permanent-for-this-session on any desktop that lacks the portal or
// declines the prompt — see GlobalShortcut's own header for why that's by design.
globalShortcut_ = new GlobalShortcut(this);
connect(globalShortcut_, &GlobalShortcut::activated, this, &MainWindow::openAddUrlDialog);
globalShortcut_->requestBinding();
#endif
}
void MainWindow::closeEvent(QCloseEvent *event) {
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
hide();
@@ -275,11 +301,11 @@ void MainWindow::closeEvent(QCloseEvent *event) {
void MainWindow::onConnectionState(rpc::ConnectionState state) {
connText_->setText(tr(rpc::toString(state)));
QString colour = QStringLiteral("#c0392b"); // red
QString colour = QLatin1String(theme::kDanger);
if (state == rpc::ConnectionState::Connected) {
colour = QStringLiteral("#27ae60"); // green
colour = QLatin1String(theme::kSuccess);
} else if (state != rpc::ConnectionState::Disconnected) {
colour = QStringLiteral("#e67e22"); // amber
colour = QLatin1String(theme::kWarning);
}
connDot_->setStyleSheet(dotStyle(colour));
@@ -299,7 +325,7 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
if (online) {
fetchTree();
fetchMinimizeToTraySetting();
fetchGeneralUiSettings();
}
}
@@ -318,29 +344,40 @@ void MainWindow::fetchTree() {
});
}
void MainWindow::fetchMinimizeToTraySetting() {
client_->call(QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}},
[this](const rpc::RpcReply &reply) {
if (reply.ok()) {
minimizeToTrayEnabled_ = reply.result.toObject()
.value("values")
.toObject()
.value("general.minimizeToTray")
.toBool();
}
});
void MainWindow::fetchGeneralUiSettings() {
client_->call(
QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray"),
QStringLiteral("general.showDropTarget")}}},
[this](const rpc::RpcReply &reply) {
if (!reply.ok()) {
return;
}
const QJsonObject values = reply.result.toObject().value("values").toObject();
minimizeToTrayEnabled_ = values.value("general.minimizeToTray").toBool();
if (dropTarget_) {
dropTarget_->setVisible(values.value("general.showDropTarget").toBool(true));
}
});
}
void MainWindow::onSettingsChanged(const QJsonObject &params) {
for (const QJsonValue &key : params.value("keys").toArray()) {
if (key.toString() == QLatin1String("general.minimizeToTray")) {
fetchMinimizeToTraySetting();
const QString k = key.toString();
if (k == QLatin1String("general.minimizeToTray") ||
k == QLatin1String("general.showDropTarget")) {
fetchGeneralUiSettings();
break;
}
}
}
void MainWindow::onUrlDropped(const QString &url) {
auto *info = new FileInfoDialog(client_, url, categoriesCache_, queuesCache_, this);
info->setAttribute(Qt::WA_DeleteOnClose);
info->show();
}
void MainWindow::openAddUrlDialog() {
AddUrlDialog dlg(this);
if (dlg.exec() != QDialog::Accepted) {
+11 -1
View File
@@ -26,6 +26,10 @@ namespace velox::gui {
class DownloadTableModel;
class CategoryPanel;
class TrayIcon;
class DropTargetWidget;
#ifdef VELOX_GUI_HAVE_DBUS
class GlobalShortcut;
#endif
namespace rpc {
class RpcClient;
} // namespace rpc
@@ -64,14 +68,16 @@ class MainWindow : public QMainWindow {
void openGrabberWizard();
void showAndRaise();
void onSettingsChanged(const QJsonObject &params);
void onUrlDropped(const QString &url);
private:
void buildActions();
void buildMenus();
void buildToolBar();
void buildTray();
void buildDropTarget();
void fetchTree();
void fetchMinimizeToTraySetting();
void fetchGeneralUiSettings();
QStringList selectedTaskIds() const;
QStringList allTaskIds() const;
void actOnTasks(const char *methodName, const QStringList &ids);
@@ -102,7 +108,11 @@ class MainWindow : public QMainWindow {
QJsonArray categoriesCache_;
QJsonArray queuesCache_;
TrayIcon *trayIcon_ = nullptr;
DropTargetWidget *dropTarget_ = nullptr;
bool minimizeToTrayEnabled_ = false;
#ifdef VELOX_GUI_HAVE_DBUS
GlobalShortcut *globalShortcut_ = nullptr;
#endif
QLabel *connDot_;
QLabel *connText_;