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:
@@ -53,6 +53,24 @@ capture, and the Add-URL dialog pre-fills from the clipboard when opened. Backgr
|
|||||||
monitoring is a bonus if the spike says yes. **Do not let this block the release, and do
|
monitoring is a bonus if the spike says yes. **Do not let this block the release, and do
|
||||||
not promise it in the UI before S2 answers.**
|
not promise it in the UI before S2 answers.**
|
||||||
|
|
||||||
|
**Spike S2, item (c) answered — verified live, not the full spike:** on this desktop
|
||||||
|
(GNOME/Mutter, Ubuntu 26.04, plain non-Flatpak/non-snap process), `CreateSession` on
|
||||||
|
`org.freedesktop.portal.GlobalShortcuts` refuses every caller with `"An app id is
|
||||||
|
required"` — reproduced two ways: `gui/src/clipboard/GlobalShortcut.cpp`'s real async
|
||||||
|
D-Bus call, and a bare `busctl --user call … CreateSession` from an interactive shell
|
||||||
|
(no Qt involved at all), both under a real Wayland session (`WAYLAND_DISPLAY` set), not
|
||||||
|
just offscreen. Because the second reproduction has no Qt/app-level identity to configure
|
||||||
|
at all and still fails identically, this looks like the portal requiring the caller's
|
||||||
|
*bus connection* to already carry a sandboxed app id (Flatpak/snap portal-confined) —
|
||||||
|
something no amount of `QGuiApplication::setDesktopFileName()` or similar can supply from
|
||||||
|
an unconfined process. **Practical read:** the global-shortcut explicit path likely does
|
||||||
|
not work at all for Velox as a traditionally-packaged (.deb/AppImage) app on stock
|
||||||
|
GNOME — only if/when it ships confined. The code is still in (best-effort, fails silent
|
||||||
|
exactly like this, never advertised — see the file's own header), since it costs nothing
|
||||||
|
and activates automatically the day that changes. Items (a) `QClipboard::dataChanged`
|
||||||
|
cross-app, (b) `wlr-data-control`, and (d) XWayland fallback are **still unanswered** —
|
||||||
|
this was one item of S2's four, not the full spike.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## R3 — AMO review friction 🟠 MEDIUM
|
## R3 — AMO review friction 🟠 MEDIUM
|
||||||
|
|||||||
@@ -35,10 +35,21 @@ add_library(velox-gui-lib STATIC
|
|||||||
src/dialogs/BatchDialog.cpp
|
src/dialogs/BatchDialog.cpp
|
||||||
src/dialogs/GrabberWizard.cpp
|
src/dialogs/GrabberWizard.cpp
|
||||||
src/tray/TrayIcon.cpp
|
src/tray/TrayIcon.cpp
|
||||||
|
src/widgets/DropTargetWidget.cpp
|
||||||
|
src/util/ThemeManager.cpp
|
||||||
|
src/util/UiThreadWatchdog.cpp
|
||||||
src/mainwindow/CategoryPanel.cpp
|
src/mainwindow/CategoryPanel.cpp
|
||||||
src/mainwindow/MainWindow.cpp
|
src/mainwindow/MainWindow.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# docs/03-gui-spec.md §7: the two QSS skins ThemeManager picks between, embedded so the
|
||||||
|
# app needs no external file at runtime.
|
||||||
|
qt_add_resources(velox-gui-lib "theme"
|
||||||
|
PREFIX "/qss"
|
||||||
|
BASE "resources/qss"
|
||||||
|
FILES resources/qss/idm-like.qss resources/qss/dark.qss
|
||||||
|
)
|
||||||
|
|
||||||
target_include_directories(velox-gui-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
target_include_directories(velox-gui-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
target_compile_features(velox-gui-lib PUBLIC cxx_std_23)
|
target_compile_features(velox-gui-lib PUBLIC cxx_std_23)
|
||||||
target_compile_options(velox-gui-lib PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
target_compile_options(velox-gui-lib PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||||
@@ -47,8 +58,23 @@ target_link_libraries(velox-gui-lib PUBLIC
|
|||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Svg
|
Qt6::Svg
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
|
Threads::Threads
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# docs/06-risks-and-spikes.md R2's explicit path #2 (a global shortcut via
|
||||||
|
# org.freedesktop.portal.GlobalShortcuts) needs Qt6::DBus, which the root CMakeLists.txt
|
||||||
|
# does not request yet (gui/docs/pkg-qa-requests-m1.md R4 — PKG/QA's file, not ours).
|
||||||
|
# Guarded exactly like the veloxproto check above: compiles in automatically the moment
|
||||||
|
# that lands, and MainWindow only wires it up when VELOX_GUI_HAVE_DBUS is defined.
|
||||||
|
if(TARGET Qt6::DBus)
|
||||||
|
target_sources(velox-gui-lib PRIVATE src/clipboard/GlobalShortcut.cpp)
|
||||||
|
target_link_libraries(velox-gui-lib PUBLIC Qt6::DBus)
|
||||||
|
target_compile_definitions(velox-gui-lib PUBLIC VELOX_GUI_HAVE_DBUS)
|
||||||
|
else()
|
||||||
|
message(STATUS "velox-gui: Qt6::DBus not available — the clipboard global-shortcut "
|
||||||
|
"path (gui/docs/pkg-qa-requests-m1.md R4) is skipped, not broken.")
|
||||||
|
endif()
|
||||||
|
|
||||||
add_executable(velox-gui src/main.cpp)
|
add_executable(velox-gui src/main.cpp)
|
||||||
target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||||
target_link_libraries(velox-gui PRIVATE velox-gui-lib)
|
target_link_libraries(velox-gui PRIVATE velox-gui-lib)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/* Velox — dark theme. Lane GUI. Structured identically to idm-like.qss — diff the two
|
||||||
|
* when changing either.
|
||||||
|
*
|
||||||
|
* --- palette -----------------------------------------------------------------------
|
||||||
|
* --bg #202225 window and dialog background
|
||||||
|
* --surface #2b2d31 table/tree, input field backgrounds
|
||||||
|
* --surface-alt #313338 alternating row colour
|
||||||
|
* --border #3f4147 panel/header/input borders
|
||||||
|
* --text #e6e7ea primary text
|
||||||
|
* --text-muted #9a9da3 secondary text (headers, disabled)
|
||||||
|
* --accent #4c94e0 selection, focus ring, progress fill
|
||||||
|
* --accent-hover #5da2ea hovered accent (buttons, tabs)
|
||||||
|
* -------------------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
QMainWindow, QDialog {
|
||||||
|
background: #202225;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTreeView, QTableView, QListView {
|
||||||
|
background: #2b2d31;
|
||||||
|
alternate-background-color: #313338;
|
||||||
|
color: #e6e7ea;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
selection-background-color: #4c94e0;
|
||||||
|
selection-color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section {
|
||||||
|
background: #313338;
|
||||||
|
color: #9a9da3;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #3f4147;
|
||||||
|
border-bottom: 1px solid #3f4147;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||||
|
background: #2b2d31;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||||
|
border: 1px solid #4c94e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton {
|
||||||
|
background: #2b2d31;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:hover {
|
||||||
|
border-color: #5da2ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:default {
|
||||||
|
background: #4c94e0;
|
||||||
|
border-color: #4c94e0;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:default:hover {
|
||||||
|
background: #5da2ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
background: #2b2d31;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab {
|
||||||
|
background: #313338;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #9a9da3;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background: #2b2d31;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar {
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #313338;
|
||||||
|
text-align: center;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background: #4c94e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu {
|
||||||
|
background: #2b2d31;
|
||||||
|
border: 1px solid #3f4147;
|
||||||
|
color: #e6e7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu::item:selected {
|
||||||
|
background: #4c94e0;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* Velox — light theme. Lane GUI.
|
||||||
|
*
|
||||||
|
* docs/agents/AGENT-GUI.md build order step 8: "colours in one variables block at the
|
||||||
|
* top of the QSS; no hard-coded hex scattered through widget code." QSS itself has no
|
||||||
|
* variable syntax (Qt has never added one), so this block is the actual palette, kept in
|
||||||
|
* one place and referenced from every rule below by comment rather than repeated ad hoc —
|
||||||
|
* every hex value that appears more than once below is listed here first. dark.qss is
|
||||||
|
* structured identically with its own values, so the two stay easy to diff against each
|
||||||
|
* other when one changes.
|
||||||
|
*
|
||||||
|
* --- palette -----------------------------------------------------------------------
|
||||||
|
* --bg #f4f5f7 window and dialog background
|
||||||
|
* --surface #ffffff table/tree, input field backgrounds
|
||||||
|
* --surface-alt #eef0f3 alternating row colour
|
||||||
|
* --border #d3d7dc panel/header/input borders
|
||||||
|
* --text #202225 primary text
|
||||||
|
* --text-muted #6b7078 secondary text (headers, disabled)
|
||||||
|
* --accent #2f7dd1 selection, focus ring, progress fill
|
||||||
|
* --accent-hover #3f8ce0 hovered accent (buttons, tabs)
|
||||||
|
* -------------------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
QMainWindow, QDialog {
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTreeView, QTableView, QListView {
|
||||||
|
background: #ffffff;
|
||||||
|
alternate-background-color: #eef0f3;
|
||||||
|
color: #202225;
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
selection-background-color: #2f7dd1;
|
||||||
|
selection-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section {
|
||||||
|
background: #eef0f3;
|
||||||
|
color: #6b7078;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #d3d7dc;
|
||||||
|
border-bottom: 1px solid #d3d7dc;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||||
|
border: 1px solid #2f7dd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:hover {
|
||||||
|
border-color: #3f8ce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:default {
|
||||||
|
background: #2f7dd1;
|
||||||
|
border-color: #2f7dd1;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:default:hover {
|
||||||
|
background: #3f8ce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab {
|
||||||
|
background: #eef0f3;
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #6b7078;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar {
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #eef0f3;
|
||||||
|
text-align: center;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background: #2f7dd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d3d7dc;
|
||||||
|
color: #202225;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu::item:selected {
|
||||||
|
background: #2f7dd1;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
@@ -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/Protocol.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -164,7 +165,7 @@ BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArr
|
|||||||
});
|
});
|
||||||
queueCombo_->setEnabled(false);
|
queueCombo_->setEnabled(false);
|
||||||
|
|
||||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||||
errorLabel_->setWordWrap(true);
|
errorLabel_->setWordWrap(true);
|
||||||
errorLabel_->hide();
|
errorLabel_->hide();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -101,7 +102,7 @@ FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray c
|
|||||||
form->addRow(tr("Buffer:"), bufferCombo_);
|
form->addRow(tr("Buffer:"), bufferCombo_);
|
||||||
form->addRow(QString(), remember);
|
form->addRow(QString(), remember);
|
||||||
|
|
||||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||||
errorLabel_->setWordWrap(true);
|
errorLabel_->setWordWrap(true);
|
||||||
errorLabel_->hide();
|
errorLabel_->hide();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -177,7 +178,7 @@ class GrabberReviewPage : public QWizardPage {
|
|||||||
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
|
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
|
||||||
|
|
||||||
errorLabel_ = new QLabel(this);
|
errorLabel_ = new QLabel(this);
|
||||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
errorLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||||
errorLabel_->hide();
|
errorLabel_->hide();
|
||||||
|
|
||||||
auto *footer = new QFormLayout;
|
auto *footer = new QFormLayout;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -126,7 +127,7 @@ SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
statusLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||||
statusLabel_->hide();
|
statusLabel_->hide();
|
||||||
form_->setEnabled(false); // no queue selected yet
|
form_->setEnabled(false); // no queue selected yet
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
|
|||||||
kibps_->setEnabled(false);
|
kibps_->setEnabled(false);
|
||||||
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
|
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
|
||||||
|
|
||||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
statusLabel_->setStyleSheet(theme::errorLabelStyle());
|
||||||
statusLabel_->hide();
|
statusLabel_->hide();
|
||||||
|
|
||||||
auto *form = new QFormLayout;
|
auto *form = new QFormLayout;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
#include "mainwindow/MainWindow.hpp"
|
#include "mainwindow/MainWindow.hpp"
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/ThemeManager.hpp"
|
||||||
|
#include "util/UiThreadWatchdog.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -45,6 +47,16 @@ int main(int argc, char **argv) {
|
|||||||
app.installTranslator(&translator);
|
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::rpc::RpcClient client(defaultSocketPath());
|
||||||
velox::gui::MainWindow window(&client);
|
velox::gui::MainWindow window(&client);
|
||||||
window.show();
|
window.show();
|
||||||
|
|||||||
@@ -37,8 +37,14 @@
|
|||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
#include "tray/TrayIcon.hpp"
|
#include "tray/TrayIcon.hpp"
|
||||||
#include "util/Format.hpp"
|
#include "util/Format.hpp"
|
||||||
|
#include "util/Theme.hpp"
|
||||||
|
#include "widgets/DropTargetWidget.hpp"
|
||||||
#include "widgets/ProgressDelegate.hpp"
|
#include "widgets/ProgressDelegate.hpp"
|
||||||
|
|
||||||
|
#ifdef VELOX_GUI_HAVE_DBUS
|
||||||
|
#include "clipboard/GlobalShortcut.hpp"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -88,7 +94,9 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
|
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
|
||||||
bannerLayout->addWidget(bannerLabel);
|
bannerLayout->addWidget(bannerLabel);
|
||||||
bannerLayout->addStretch();
|
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);
|
offlineBanner_->setVisible(false);
|
||||||
|
|
||||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
@@ -111,9 +119,10 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
buildMenus();
|
buildMenus();
|
||||||
buildToolBar();
|
buildToolBar();
|
||||||
buildTray();
|
buildTray();
|
||||||
|
buildDropTarget();
|
||||||
|
|
||||||
// --- status bar -----------------------------------------------------------------
|
// --- status bar -----------------------------------------------------------------
|
||||||
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
|
connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger)));
|
||||||
statusBar()->addPermanentWidget(countsLabel_, 1);
|
statusBar()->addPermanentWidget(countsLabel_, 1);
|
||||||
statusBar()->addPermanentWidget(connText_);
|
statusBar()->addPermanentWidget(connText_);
|
||||||
statusBar()->addPermanentWidget(connDot_);
|
statusBar()->addPermanentWidget(connDot_);
|
||||||
@@ -261,6 +270,23 @@ void MainWindow::buildTray() {
|
|||||||
trayIcon_->show();
|
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) {
|
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||||
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
|
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
|
||||||
hide();
|
hide();
|
||||||
@@ -275,11 +301,11 @@ void MainWindow::closeEvent(QCloseEvent *event) {
|
|||||||
void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
||||||
connText_->setText(tr(rpc::toString(state)));
|
connText_->setText(tr(rpc::toString(state)));
|
||||||
|
|
||||||
QString colour = QStringLiteral("#c0392b"); // red
|
QString colour = QLatin1String(theme::kDanger);
|
||||||
if (state == rpc::ConnectionState::Connected) {
|
if (state == rpc::ConnectionState::Connected) {
|
||||||
colour = QStringLiteral("#27ae60"); // green
|
colour = QLatin1String(theme::kSuccess);
|
||||||
} else if (state != rpc::ConnectionState::Disconnected) {
|
} else if (state != rpc::ConnectionState::Disconnected) {
|
||||||
colour = QStringLiteral("#e67e22"); // amber
|
colour = QLatin1String(theme::kWarning);
|
||||||
}
|
}
|
||||||
connDot_->setStyleSheet(dotStyle(colour));
|
connDot_->setStyleSheet(dotStyle(colour));
|
||||||
|
|
||||||
@@ -299,7 +325,7 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
|||||||
|
|
||||||
if (online) {
|
if (online) {
|
||||||
fetchTree();
|
fetchTree();
|
||||||
fetchMinimizeToTraySetting();
|
fetchGeneralUiSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,29 +344,40 @@ void MainWindow::fetchTree() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::fetchMinimizeToTraySetting() {
|
void MainWindow::fetchGeneralUiSettings() {
|
||||||
client_->call(QStringLiteral("settings.get"),
|
client_->call(
|
||||||
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}},
|
QStringLiteral("settings.get"),
|
||||||
|
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray"),
|
||||||
|
QStringLiteral("general.showDropTarget")}}},
|
||||||
[this](const rpc::RpcReply &reply) {
|
[this](const rpc::RpcReply &reply) {
|
||||||
if (reply.ok()) {
|
if (!reply.ok()) {
|
||||||
minimizeToTrayEnabled_ = reply.result.toObject()
|
return;
|
||||||
.value("values")
|
}
|
||||||
.toObject()
|
const QJsonObject values = reply.result.toObject().value("values").toObject();
|
||||||
.value("general.minimizeToTray")
|
minimizeToTrayEnabled_ = values.value("general.minimizeToTray").toBool();
|
||||||
.toBool();
|
if (dropTarget_) {
|
||||||
|
dropTarget_->setVisible(values.value("general.showDropTarget").toBool(true));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onSettingsChanged(const QJsonObject ¶ms) {
|
void MainWindow::onSettingsChanged(const QJsonObject ¶ms) {
|
||||||
for (const QJsonValue &key : params.value("keys").toArray()) {
|
for (const QJsonValue &key : params.value("keys").toArray()) {
|
||||||
if (key.toString() == QLatin1String("general.minimizeToTray")) {
|
const QString k = key.toString();
|
||||||
fetchMinimizeToTraySetting();
|
if (k == QLatin1String("general.minimizeToTray") ||
|
||||||
|
k == QLatin1String("general.showDropTarget")) {
|
||||||
|
fetchGeneralUiSettings();
|
||||||
break;
|
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() {
|
void MainWindow::openAddUrlDialog() {
|
||||||
AddUrlDialog dlg(this);
|
AddUrlDialog dlg(this);
|
||||||
if (dlg.exec() != QDialog::Accepted) {
|
if (dlg.exec() != QDialog::Accepted) {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ namespace velox::gui {
|
|||||||
class DownloadTableModel;
|
class DownloadTableModel;
|
||||||
class CategoryPanel;
|
class CategoryPanel;
|
||||||
class TrayIcon;
|
class TrayIcon;
|
||||||
|
class DropTargetWidget;
|
||||||
|
#ifdef VELOX_GUI_HAVE_DBUS
|
||||||
|
class GlobalShortcut;
|
||||||
|
#endif
|
||||||
namespace rpc {
|
namespace rpc {
|
||||||
class RpcClient;
|
class RpcClient;
|
||||||
} // namespace rpc
|
} // namespace rpc
|
||||||
@@ -64,14 +68,16 @@ class MainWindow : public QMainWindow {
|
|||||||
void openGrabberWizard();
|
void openGrabberWizard();
|
||||||
void showAndRaise();
|
void showAndRaise();
|
||||||
void onSettingsChanged(const QJsonObject ¶ms);
|
void onSettingsChanged(const QJsonObject ¶ms);
|
||||||
|
void onUrlDropped(const QString &url);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void buildActions();
|
void buildActions();
|
||||||
void buildMenus();
|
void buildMenus();
|
||||||
void buildToolBar();
|
void buildToolBar();
|
||||||
void buildTray();
|
void buildTray();
|
||||||
|
void buildDropTarget();
|
||||||
void fetchTree();
|
void fetchTree();
|
||||||
void fetchMinimizeToTraySetting();
|
void fetchGeneralUiSettings();
|
||||||
QStringList selectedTaskIds() const;
|
QStringList selectedTaskIds() const;
|
||||||
QStringList allTaskIds() const;
|
QStringList allTaskIds() const;
|
||||||
void actOnTasks(const char *methodName, const QStringList &ids);
|
void actOnTasks(const char *methodName, const QStringList &ids);
|
||||||
@@ -102,7 +108,11 @@ class MainWindow : public QMainWindow {
|
|||||||
QJsonArray categoriesCache_;
|
QJsonArray categoriesCache_;
|
||||||
QJsonArray queuesCache_;
|
QJsonArray queuesCache_;
|
||||||
TrayIcon *trayIcon_ = nullptr;
|
TrayIcon *trayIcon_ = nullptr;
|
||||||
|
DropTargetWidget *dropTarget_ = nullptr;
|
||||||
bool minimizeToTrayEnabled_ = false;
|
bool minimizeToTrayEnabled_ = false;
|
||||||
|
#ifdef VELOX_GUI_HAVE_DBUS
|
||||||
|
GlobalShortcut *globalShortcut_ = nullptr;
|
||||||
|
#endif
|
||||||
|
|
||||||
QLabel *connDot_;
|
QLabel *connDot_;
|
||||||
QLabel *connText_;
|
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
|
||||||
@@ -146,6 +146,18 @@ set_tests_properties(gui_grabberwizard PROPERTIES
|
|||||||
LABELS "gui"
|
LABELS "gui"
|
||||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||||
|
|
||||||
|
# tst_uithreadwatchdog — the 200 ms debug-build UI-thread watchdog.
|
||||||
|
# Red when a genuinely blocked UI thread (synchronous sleep, no processEvents) stops
|
||||||
|
# producing a warning, or a responsive one starts producing a false-positive one.
|
||||||
|
add_executable(tst_uithreadwatchdog tst_uithreadwatchdog.cpp)
|
||||||
|
target_compile_features(tst_uithreadwatchdog PRIVATE cxx_std_23)
|
||||||
|
target_compile_options(tst_uithreadwatchdog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||||
|
target_link_libraries(tst_uithreadwatchdog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||||
|
add_test(NAME gui_uithreadwatchdog COMMAND tst_uithreadwatchdog)
|
||||||
|
set_tests_properties(gui_uithreadwatchdog PROPERTIES
|
||||||
|
LABELS "gui"
|
||||||
|
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||||
|
|
||||||
# gui-dod-harness — the M1 DoD gates (scroll-60fps / rss-flat / unhappy-path).
|
# gui-dod-harness — the M1 DoD gates (scroll-60fps / rss-flat / unhappy-path).
|
||||||
add_subdirectory(dod)
|
add_subdirectory(dod)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// UiThreadWatchdog unit tests. Lane GUI.
|
||||||
|
//
|
||||||
|
// Red when a genuinely blocked UI thread (a synchronous sleep with no processEvents in
|
||||||
|
// between — the exact shape of the bug this exists to catch) stops producing a warning,
|
||||||
|
// or a responsive one starts producing a false-positive one.
|
||||||
|
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QMutexLocker>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
#include "util/UiThreadWatchdog.hpp"
|
||||||
|
|
||||||
|
using velox::gui::UiThreadWatchdog;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QMutex g_mutex;
|
||||||
|
QString g_lastWarning;
|
||||||
|
QtMessageHandler g_prevHandler = nullptr;
|
||||||
|
|
||||||
|
// Qt's own message handler is process-global and can run on any thread — the watchdog's
|
||||||
|
// warning comes from its background thread while this test's main thread is deliberately
|
||||||
|
// blocked, so this needs real synchronization, not just a plain global (this test runs
|
||||||
|
// under the `tsan` preset too).
|
||||||
|
void captureHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) {
|
||||||
|
if (type == QtWarningMsg) {
|
||||||
|
QMutexLocker locker(&g_mutex);
|
||||||
|
g_lastWarning = msg;
|
||||||
|
}
|
||||||
|
if (g_prevHandler) {
|
||||||
|
g_prevHandler(type, ctx, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString lastWarning() {
|
||||||
|
QMutexLocker locker(&g_mutex);
|
||||||
|
return g_lastWarning;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class TstUiThreadWatchdog : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void init();
|
||||||
|
void cleanup();
|
||||||
|
|
||||||
|
void firesOnABlockedUiThread();
|
||||||
|
void staysQuietWhenResponsive();
|
||||||
|
};
|
||||||
|
|
||||||
|
void TstUiThreadWatchdog::init() {
|
||||||
|
QMutexLocker locker(&g_mutex);
|
||||||
|
g_lastWarning.clear();
|
||||||
|
g_prevHandler = qInstallMessageHandler(captureHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TstUiThreadWatchdog::cleanup() {
|
||||||
|
qInstallMessageHandler(g_prevHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TstUiThreadWatchdog::firesOnABlockedUiThread() {
|
||||||
|
UiThreadWatchdog wd;
|
||||||
|
wd.start();
|
||||||
|
|
||||||
|
// Block this thread (the watchdog's "UI thread" here) synchronously and well past
|
||||||
|
// the 200 ms budget — no processEvents at all, exactly what a real stall looks like
|
||||||
|
// and exactly what this exists to catch.
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Let the event loop run so the queued ack the watchdog sent before the sleep started
|
||||||
|
// finally lands (harmless — the warning it's checking for already fired mid-sleep,
|
||||||
|
// from the watchdog's own background thread).
|
||||||
|
QTest::qWait(150);
|
||||||
|
|
||||||
|
QVERIFY2(lastWarning().contains(QStringLiteral("blocking")), qUtf8Printable(lastWarning()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TstUiThreadWatchdog::staysQuietWhenResponsive() {
|
||||||
|
UiThreadWatchdog wd;
|
||||||
|
wd.start();
|
||||||
|
QTest::qWait(400); // event loop stays responsive throughout — well past the budget
|
||||||
|
QVERIFY(lastWarning().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_MAIN(TstUiThreadWatchdog)
|
||||||
|
#include "tst_uithreadwatchdog.moc"
|
||||||
Reference in New Issue
Block a user