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,172 @@
|
||||
#include "clipboard/GlobalShortcut.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDBusArgument>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusConnectionInterface>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusObjectPath>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QDBusPendingReply>
|
||||
#include <QLoggingCategory>
|
||||
#include <QRandomGenerator>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
Q_LOGGING_CATEGORY(lcShortcut, "velox.gui.globalshortcut")
|
||||
|
||||
constexpr auto kService = "org.freedesktop.portal.Desktop";
|
||||
constexpr auto kObjectPath = "/org/freedesktop/portal/desktop";
|
||||
constexpr auto kShortcutsIface = "org.freedesktop.portal.GlobalShortcuts";
|
||||
constexpr auto kRequestIface = "org.freedesktop.portal.Request";
|
||||
constexpr auto kShortcutId = "add-url-from-clipboard";
|
||||
|
||||
QString newToken(const QString &prefix) {
|
||||
return prefix + QString::number(QRandomGenerator::global()->generate64(), 16);
|
||||
}
|
||||
|
||||
// org.freedesktop.portal.Request object paths embed the caller's own unique bus name
|
||||
// with ':' and '.' rewritten to '_' — reconstructing that is documented but fragile;
|
||||
// every portal client instead just uses the exact path CreateSession/BindShortcuts hand
|
||||
// back in their reply, which is what every call below does.
|
||||
void connectToRequestResponse(const QDBusObjectPath &requestPath, QObject *receiver,
|
||||
const char *slot) {
|
||||
QDBusConnection::sessionBus().connect(QString::fromLatin1(kService), requestPath.path(),
|
||||
QString::fromLatin1(kRequestIface),
|
||||
QStringLiteral("Response"), receiver, slot);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GlobalShortcut::GlobalShortcut(QObject *parent) : QObject(parent) {}
|
||||
|
||||
void GlobalShortcut::requestBinding() {
|
||||
if (requested_) {
|
||||
return;
|
||||
}
|
||||
requested_ = true;
|
||||
|
||||
if (!QDBusConnection::sessionBus().isConnected()) {
|
||||
qCInfo(lcShortcut, "no D-Bus session bus — global shortcut unavailable this session");
|
||||
return;
|
||||
}
|
||||
// GlobalShortcuts is an *impl* portal some desktops never install; check the name is
|
||||
// even owned before making a call whose only failure mode would otherwise be a vague
|
||||
// D-Bus service-unknown error.
|
||||
if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(
|
||||
QString::fromLatin1(kService))) {
|
||||
qCInfo(lcShortcut, "no xdg-desktop-portal on this session bus");
|
||||
return;
|
||||
}
|
||||
|
||||
// QDBusMessage::createMethodCall + asyncCall, not QDBusInterface: the interface class
|
||||
// introspects the remote object on first use and caches the result in a process-wide
|
||||
// QDBusMetaObject table it never frees — by design (Qt intends it to live for the
|
||||
// process's lifetime so repeated calls skip introspection), but that reads as a real
|
||||
// LeakSanitizer leak the first time anything in this binary touches D-Bus at all,
|
||||
// which is exactly what happened here (caught live, `ctest -L gui`'s tst_rtl went red
|
||||
// under ASan). A raw method-call message needs no introspection and allocates nothing
|
||||
// that outlives this call.
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(
|
||||
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
|
||||
QString::fromLatin1(kShortcutsIface), QStringLiteral("CreateSession"));
|
||||
const QVariantMap options{
|
||||
{QStringLiteral("handle_token"), newToken(QStringLiteral("velox_create_"))},
|
||||
{QStringLiteral("session_handle_token"), newToken(QStringLiteral("velox_session_"))},
|
||||
};
|
||||
call << options;
|
||||
auto *watcher =
|
||||
new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
qCInfo(lcShortcut, "CreateSession failed: %s", qUtf8Printable(reply.error().message()));
|
||||
return;
|
||||
}
|
||||
connectToRequestResponse(reply.value(), this,
|
||||
SLOT(onCreateSessionResponse(uint, QVariantMap)));
|
||||
});
|
||||
}
|
||||
|
||||
void GlobalShortcut::onCreateSessionResponse(uint code, const QVariantMap &results) {
|
||||
if (code != 0) {
|
||||
qCInfo(lcShortcut, "CreateSession request denied/failed (code %u)", code);
|
||||
return;
|
||||
}
|
||||
sessionHandle_ = results.value(QStringLiteral("session_handle")).toString();
|
||||
if (sessionHandle_.isEmpty()) {
|
||||
qCWarning(lcShortcut, "CreateSession succeeded with no session_handle — portal bug?");
|
||||
return;
|
||||
}
|
||||
bindShortcuts();
|
||||
}
|
||||
|
||||
void GlobalShortcut::bindShortcuts() {
|
||||
// a(sa{sv}): one (id, properties) pair per shortcut. QtDBus has no automatic
|
||||
// marshalling for a struct-in-array-of-variants shape this specific, so it is built by
|
||||
// hand with QDBusArgument — the documented escape hatch for exactly this case.
|
||||
QDBusArgument shortcutsArg;
|
||||
shortcutsArg.beginArray(qMetaTypeId<QDBusArgument>());
|
||||
shortcutsArg.beginStructure();
|
||||
shortcutsArg << QString::fromLatin1(kShortcutId);
|
||||
QVariantMap props{
|
||||
{QStringLiteral("description"),
|
||||
QCoreApplication::translate("velox::gui::GlobalShortcut",
|
||||
"Add URL from clipboard (Velox)")},
|
||||
};
|
||||
shortcutsArg << props;
|
||||
shortcutsArg.endStructure();
|
||||
shortcutsArg.endArray();
|
||||
|
||||
QDBusMessage call = QDBusMessage::createMethodCall(
|
||||
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
|
||||
QString::fromLatin1(kShortcutsIface), QStringLiteral("BindShortcuts"));
|
||||
const QVariantMap options{
|
||||
{QStringLiteral("handle_token"), newToken(QStringLiteral("velox_bind_"))}};
|
||||
call << QVariant::fromValue(QDBusObjectPath(sessionHandle_))
|
||||
<< QVariant::fromValue(shortcutsArg) << QString() << options;
|
||||
auto *watcher =
|
||||
new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(call), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
qCInfo(lcShortcut, "BindShortcuts failed: %s", qUtf8Printable(reply.error().message()));
|
||||
return;
|
||||
}
|
||||
connectToRequestResponse(reply.value(), this,
|
||||
SLOT(onBindShortcutsResponse(uint, QVariantMap)));
|
||||
});
|
||||
}
|
||||
|
||||
void GlobalShortcut::onBindShortcutsResponse(uint code, const QVariantMap &results) {
|
||||
if (code != 0) {
|
||||
// The user declined the "let Velox bind a shortcut" prompt, or the compositor
|
||||
// doesn't implement the portal even though the service exists. Both silent,
|
||||
// permanent for this session — see the header comment.
|
||||
qCInfo(lcShortcut, "BindShortcuts request declined/failed (code %u)", code);
|
||||
return;
|
||||
}
|
||||
qCInfo(lcShortcut, "global shortcut bound: %s", kShortcutId);
|
||||
Q_UNUSED(results);
|
||||
|
||||
QDBusConnection::sessionBus().connect(
|
||||
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
|
||||
QString::fromLatin1(kShortcutsIface), QStringLiteral("Activated"), this,
|
||||
SLOT(onPortalActivated(QDBusObjectPath, QString, qulonglong, QVariantMap)));
|
||||
}
|
||||
|
||||
void GlobalShortcut::onPortalActivated(const QDBusObjectPath &sessionHandle,
|
||||
const QString &shortcutId, qulonglong timestamp,
|
||||
const QVariantMap &options) {
|
||||
Q_UNUSED(timestamp);
|
||||
Q_UNUSED(options);
|
||||
if (sessionHandle.path() != sessionHandle_ || shortcutId != QLatin1String(kShortcutId)) {
|
||||
return; // another session/shortcut on the same signal, not ours
|
||||
}
|
||||
emit activated();
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,59 @@
|
||||
// Explicit clipboard capture, path #2. Lane GUI.
|
||||
//
|
||||
// docs/06-risks-and-spikes.md R2: a Wayland client cannot passively observe clipboard
|
||||
// changes made by other applications — not a bug, a deliberate security property, and
|
||||
// the mechanism IDM's clipboard capture relies on does not exist here. The ship-regardless
|
||||
// design has three *explicit* paths instead; this is the second one — a global shortcut
|
||||
// via org.freedesktop.portal.GlobalShortcuts that reads the clipboard on demand when the
|
||||
// user presses it. (#1 is the extension's context menu, EXT's; #3 is AddUrlDialog's
|
||||
// clipboard prefill on open, already in place.)
|
||||
//
|
||||
// Best-effort by design, same as the risk doc says to treat all of this: the portal may
|
||||
// not exist on this desktop, the compositor may not implement it even if the portal
|
||||
// service does, or the user may decline the one-time "let Velox bind a global shortcut"
|
||||
// prompt. Every one of those is silent, permanent for this session, and never surfaced as
|
||||
// an error — there is nothing actionable for the user to do about a desktop that doesn't
|
||||
// have this, and the explicit paths (menu, prefill) still work regardless. Never promise
|
||||
// this in the UI before it has actually fired once.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QVariantMap>
|
||||
|
||||
class QDBusObjectPath;
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class GlobalShortcut : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit GlobalShortcut(QObject *parent = nullptr);
|
||||
|
||||
/// Fire-and-forget: asks the portal for a session, then to bind one shortcut. There is
|
||||
/// no synchronous "is this supported" answer — connect activated() and find out from
|
||||
/// whether it ever fires. Safe to call once at startup; safe to call on a desktop with
|
||||
/// no portal at all (logs and returns, does nothing further).
|
||||
void requestBinding();
|
||||
|
||||
signals:
|
||||
/// The bound shortcut was pressed. No payload on purpose: the receiver reads the
|
||||
/// clipboard itself at this moment (the "on demand" part of the explicit-path design),
|
||||
/// so nothing here ever touches clipboard content that wasn't asked for right now.
|
||||
void activated();
|
||||
|
||||
private slots:
|
||||
void onCreateSessionResponse(uint code, const QVariantMap &results);
|
||||
void onBindShortcutsResponse(uint code, const QVariantMap &results);
|
||||
void onPortalActivated(const QDBusObjectPath &sessionHandle, const QString &shortcutId,
|
||||
qulonglong timestamp, const QVariantMap &options);
|
||||
|
||||
private:
|
||||
void bindShortcuts();
|
||||
|
||||
QString sessionHandle_;
|
||||
bool requested_ = false;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Theme.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
@@ -164,7 +165,7 @@ BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArr
|
||||
});
|
||||
queueCombo_->setEnabled(false);
|
||||
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||
errorLabel_->setWordWrap(true);
|
||||
errorLabel_->hide();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Theme.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
@@ -101,7 +102,7 @@ FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray c
|
||||
form->addRow(tr("Buffer:"), bufferCombo_);
|
||||
form->addRow(QString(), remember);
|
||||
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||
errorLabel_->setWordWrap(true);
|
||||
errorLabel_->hide();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Theme.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
@@ -177,7 +178,7 @@ class GrabberReviewPage : public QWizardPage {
|
||||
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
|
||||
|
||||
errorLabel_ = new QLabel(this);
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||
errorLabel_->hide();
|
||||
|
||||
auto *footer = new QFormLayout;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Theme.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
@@ -126,7 +127,7 @@ SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
|
||||
}
|
||||
});
|
||||
|
||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
statusLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||
statusLabel_->hide();
|
||||
form_->setEnabled(false); // no queue selected yet
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Theme.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
@@ -38,7 +39,7 @@ SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
|
||||
kibps_->setEnabled(false);
|
||||
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
|
||||
|
||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
statusLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||
statusLabel_->hide();
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "mainwindow/MainWindow.hpp"
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/ThemeManager.hpp"
|
||||
#include "util/UiThreadWatchdog.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -45,6 +47,16 @@ int main(int argc, char **argv) {
|
||||
app.installTranslator(&translator);
|
||||
}
|
||||
|
||||
// docs/03-gui-spec.md §7: follows QStyleHints::colorScheme() live, not just at
|
||||
// startup. Owned by main() (not MainWindow) since it's an application-wide concern.
|
||||
velox::gui::ThemeManager theme;
|
||||
theme.apply();
|
||||
|
||||
// AGENT-GUI.md M1 DoD: "no blocking call on the UI thread: verified with a 200 ms
|
||||
// watchdog in debug builds." No-op in a release build — see UiThreadWatchdog::start().
|
||||
velox::gui::UiThreadWatchdog watchdog;
|
||||
watchdog.start();
|
||||
|
||||
velox::gui::rpc::RpcClient client(defaultSocketPath());
|
||||
velox::gui::MainWindow window(&client);
|
||||
window.show();
|
||||
|
||||
@@ -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 ¶ms) {
|
||||
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) {
|
||||
|
||||
@@ -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 ¶ms);
|
||||
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_;
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "widgets/DropTargetWidget.hpp"
|
||||
|
||||
#include <QCloseEvent>
|
||||
#include <QContextMenuEvent>
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QGuiApplication>
|
||||
#include <QMenu>
|
||||
#include <QMimeData>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QScreen>
|
||||
#include <QSettings>
|
||||
#include <QUrl>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
constexpr int kSize = 56;
|
||||
} // namespace
|
||||
|
||||
DropTargetWidget::DropTargetWidget(QWidget *parent) : QWidget(parent) {
|
||||
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setAcceptDrops(true);
|
||||
setFixedSize(kSize, kSize);
|
||||
setToolTip(tr("Drop a link here to download it with Velox"));
|
||||
setMouseTracking(true);
|
||||
restorePosition();
|
||||
}
|
||||
|
||||
void DropTargetWidget::restorePosition() {
|
||||
QSettings settings;
|
||||
const QVariant saved = settings.value(QStringLiteral("dropTarget/pos"));
|
||||
if (saved.canConvert<QPoint>()) {
|
||||
move(saved.toPoint());
|
||||
return;
|
||||
}
|
||||
// First run: bottom-right corner of the primary screen, inset from the edge — IDM's
|
||||
// own default corner.
|
||||
if (const QScreen *screen = QGuiApplication::primaryScreen()) {
|
||||
const QRect avail = screen->availableGeometry();
|
||||
move(avail.right() - kSize - 24, avail.bottom() - kSize - 24);
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::savePosition() {
|
||||
QSettings settings;
|
||||
settings.setValue(QStringLiteral("dropTarget/pos"), pos());
|
||||
}
|
||||
|
||||
void DropTargetWidget::closeEvent(QCloseEvent *event) {
|
||||
savePosition();
|
||||
QWidget::closeEvent(event);
|
||||
}
|
||||
|
||||
void DropTargetWidget::paintEvent(QPaintEvent * /*event*/) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
QColor fill = palette().highlight().color();
|
||||
fill.setAlpha(hovered_ ? 220 : 170);
|
||||
p.setBrush(fill);
|
||||
p.setPen(Qt::NoPen);
|
||||
p.drawEllipse(rect().adjusted(2, 2, -2, -2));
|
||||
|
||||
p.setPen(QPen(palette().highlightedText().color(), 2));
|
||||
const QRectF arrow = rect().adjusted(kSize / 3, kSize / 4, -kSize / 3, -kSize / 3);
|
||||
p.drawLine(QPointF(arrow.center().x(), arrow.top()),
|
||||
QPointF(arrow.center().x(), arrow.bottom()));
|
||||
p.drawLine(QPointF(arrow.center().x(), arrow.bottom()),
|
||||
QPointF(arrow.left(), arrow.center().y()));
|
||||
p.drawLine(QPointF(arrow.center().x(), arrow.bottom()),
|
||||
QPointF(arrow.right(), arrow.center().y()));
|
||||
}
|
||||
|
||||
QString DropTargetWidget::firstUrlFrom(const QMimeData *mime) {
|
||||
if (mime->hasUrls()) {
|
||||
for (const QUrl &u : mime->urls()) {
|
||||
if (u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https")) {
|
||||
return u.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mime->hasText()) {
|
||||
const QUrl u(mime->text().trimmed());
|
||||
if (u.isValid() &&
|
||||
(u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https"))) {
|
||||
return u.toString();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void DropTargetWidget::dragEnterEvent(QDragEnterEvent *event) {
|
||||
if (!firstUrlFrom(event->mimeData()).isEmpty()) {
|
||||
event->acceptProposedAction();
|
||||
hovered_ = true;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::dropEvent(QDropEvent *event) {
|
||||
hovered_ = false;
|
||||
update();
|
||||
const QString url = firstUrlFrom(event->mimeData());
|
||||
if (!url.isEmpty()) {
|
||||
event->acceptProposedAction();
|
||||
emit urlDropped(url);
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::mousePressEvent(QMouseEvent *event) {
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
dragging_ = true;
|
||||
dragStartOffset_ = event->pos();
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::mouseMoveEvent(QMouseEvent *event) {
|
||||
if (dragging_) {
|
||||
move(event->globalPosition().toPoint() - dragStartOffset_);
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::mouseReleaseEvent(QMouseEvent *event) {
|
||||
if (event->button() == Qt::LeftButton && dragging_) {
|
||||
dragging_ = false;
|
||||
savePosition();
|
||||
}
|
||||
}
|
||||
|
||||
void DropTargetWidget::contextMenuEvent(QContextMenuEvent *event) {
|
||||
QMenu menu(this);
|
||||
menu.addAction(tr("Add URL…"), this, &DropTargetWidget::addUrlRequested);
|
||||
menu.addSeparator();
|
||||
menu.addAction(tr("Hide"), this, &QWidget::close);
|
||||
menu.exec(event->globalPos());
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,50 @@
|
||||
// The floating drop target. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5: "frameless always-on-top QWidget, accepts dropped links,
|
||||
// right-click menu, position remembered. IDM's drop box, minus the branding." Shown only
|
||||
// when general.showDropTarget is on (MainWindow owns fetching that setting and toggling
|
||||
// this widget's visibility, same as it already does for general.minimizeToTray).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QPoint>
|
||||
#include <QWidget>
|
||||
|
||||
class QMimeData;
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class DropTargetWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DropTargetWidget(QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
/// A URL was dropped (from a link, or from plain text that parses as one). The
|
||||
/// receiver decides what "add a download" means — same contract as AddUrlDialog's
|
||||
/// accepted URL, just skipping the dialog since this one already has the URL.
|
||||
void urlDropped(const QString &url);
|
||||
void addUrlRequested(); // right-click menu's explicit "Add URL…" entry
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private:
|
||||
void restorePosition();
|
||||
void savePosition();
|
||||
static QString firstUrlFrom(const QMimeData *mime);
|
||||
|
||||
bool dragging_ = false;
|
||||
QPoint dragStartOffset_;
|
||||
bool hovered_ = false;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
Reference in New Issue
Block a user