Skip to content

Commit 0ce906a

Browse files
Add SVG support and improve Linux tray behavior
Add SVG tray icon support and make Linux tray handling more robust: - CMake: include icons/*.svg, link Qt Svg (Qt5/Qt6), and copy icon.svg to build dir. - Add new icons/icon.svg asset. - src/tray_linux.cpp: improve Wayland detection, calculate menu position with preferred position, derive screen anchor, retry popup logic, robust icon resolution (file, pixmap, themed), set application/desktop names, and fallback standard icon. - tests/unit/test_tray.cpp: ensure test icons are copied into the test binary directory, add TestTrayIconSvgFile unit test. These changes fix issues with blank tray icons (Qt/SNI) and unreliable menu placement on Wayland, and add SVG icon support across platforms.
1 parent 6f5e2f5 commit 0ce906a

4 files changed

Lines changed: 217 additions & 73 deletions

File tree

CMakeLists.txt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ find_package(PkgConfig)
3434
file(GLOB TRAY_SOURCES
3535
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.h"
3636
"${CMAKE_CURRENT_SOURCE_DIR}/icons/*.ico"
37-
"${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png")
37+
"${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png"
38+
"${CMAKE_CURRENT_SOURCE_DIR}/icons/*.svg"
39+
)
3840

3941
if(WIN32)
4042
list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c")
@@ -44,11 +46,11 @@ else()
4446
find_library(COCOA Cocoa REQUIRED)
4547
list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m")
4648
else()
47-
find_package(Qt6 QUIET COMPONENTS Widgets DBus)
49+
find_package(Qt6 QUIET COMPONENTS Widgets DBus Svg)
4850
if(Qt6_FOUND)
4951
set(TRAY_QT_VERSION 6)
5052
else()
51-
find_package(Qt5 REQUIRED COMPONENTS Widgets DBus)
53+
find_package(Qt5 REQUIRED COMPONENTS Widgets DBus Svg)
5254
set(TRAY_QT_VERSION 5)
5355
endif()
5456
set(CMAKE_AUTOMOC ON)
@@ -74,9 +76,9 @@ else()
7476
else()
7577
list(APPEND TRAY_DEFINITIONS TRAY_QT=1)
7678
if(TRAY_QT_VERSION EQUAL 6)
77-
list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus)
79+
list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg)
7880
else()
79-
list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus)
81+
list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus Qt5::Svg)
8082
endif()
8183
endif()
8284
endif()
@@ -89,6 +91,7 @@ target_link_libraries(tray_example tray::tray)
8991

9092
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico" "${CMAKE_CURRENT_BINARY_DIR}/icon.ico" COPYONLY)
9193
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png" "${CMAKE_CURRENT_BINARY_DIR}/icon.png" COPYONLY)
94+
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg" "${CMAKE_CURRENT_BINARY_DIR}/icon.svg" COPYONLY)
9295

9396
INSTALL(TARGETS tray tray DESTINATION lib)
9497

icons/icon.svg

Lines changed: 5 additions & 0 deletions
Loading

src/tray_linux.cpp

Lines changed: 169 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
// Qt includes
1313
#include <QApplication>
14+
#include <QCoreApplication>
1415
#include <QCursor>
1516
#include <QDBusConnection>
1617
#include <QDBusInterface>
@@ -21,6 +22,7 @@
2122
#include <QMenu>
2223
#include <QPixmap>
2324
#include <QScreen>
25+
#include <QStyle>
2426
#include <QSystemTrayIcon>
2527
#include <QTimer>
2628
#include <QUrl>
@@ -69,6 +71,39 @@ namespace {
6971
uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup
7072
void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
7173

74+
bool is_wayland_session() {
75+
const QString platform = QGuiApplication::platformName().toLower();
76+
if (platform.contains(QStringLiteral("wayland"))) {
77+
return true;
78+
}
79+
return !qgetenv("WAYLAND_DISPLAY").isEmpty();
80+
}
81+
82+
QPoint screen_anchor_point(const QScreen *screen) {
83+
if (screen == nullptr) {
84+
return QPoint();
85+
}
86+
87+
const QRect full = screen->geometry();
88+
const QRect avail = screen->availableGeometry();
89+
90+
if (avail.top() > full.top()) {
91+
return QPoint(avail.right(), avail.top());
92+
}
93+
if (avail.bottom() < full.bottom()) {
94+
return QPoint(avail.right(), avail.bottom());
95+
}
96+
if (avail.left() > full.left()) {
97+
return QPoint(avail.left(), avail.bottom());
98+
}
99+
if (avail.right() < full.right()) {
100+
return QPoint(avail.right(), avail.bottom());
101+
}
102+
103+
// Some compositors report no reserved panel area; top-right is a safer fallback than (0, 0).
104+
return avail.topRight();
105+
}
106+
72107
/**
73108
* @brief Qt message handler that forwards to the registered log callback.
74109
* @param type The Qt message type.
@@ -114,48 +149,124 @@ namespace {
114149
*
115150
* @return The point at which to show the context menu.
116151
*/
117-
QPoint calculateMenuPosition() {
152+
QPoint calculateMenuPosition(const QPoint &preferred_pos = QPoint()) {
118153
if (g_tray_icon != nullptr) {
119154
const QRect iconGeo = g_tray_icon->geometry();
120155
if (iconGeo.isValid()) {
121156
return iconGeo.bottomLeft();
122157
}
123158
}
124159

160+
if (!preferred_pos.isNull() && !is_wayland_session()) {
161+
return preferred_pos;
162+
}
163+
125164
// When running under a Wayland compositor, XWayland cursor coordinates are stale
126165
// for events originating from Wayland-native surfaces (e.g., the GNOME top bar).
127166
// Detect a Wayland session regardless of the Qt platform plugin in use.
128-
const bool wayland_session = !qgetenv("WAYLAND_DISPLAY").isEmpty();
167+
const bool wayland_session = is_wayland_session();
129168
if (!wayland_session) {
130169
// Pure Xorg: QCursor::pos() is accurate.
131170
return QCursor::pos();
132171
}
133172

134-
// Wayland session fallback: infer the panel edge from available vs full screen
135-
// geometry and anchor the menu to that edge. popup() keeps the menu on-screen.
136-
QScreen *screen = QGuiApplication::primaryScreen();
137-
if (screen != nullptr) {
138-
const QRect full = screen->geometry();
139-
const QRect avail = screen->availableGeometry();
140-
if (avail.top() > full.top()) {
141-
// Panel at top (e.g., GNOME default): anchor below the panel at the right edge.
142-
return QPoint(avail.right(), avail.top());
173+
const QPoint cursor_pos = QCursor::pos();
174+
if (!cursor_pos.isNull()) {
175+
QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos);
176+
if (cursor_screen != nullptr) {
177+
return cursor_pos;
143178
}
144-
if (avail.bottom() < full.bottom()) {
145-
// Panel at the bottom (e.g., KDE Plasma default): popup() flips upward automatically.
146-
return QPoint(avail.right(), avail.bottom());
179+
}
180+
181+
// Wayland session fallback: infer panel anchor from the relevant screen.
182+
QScreen *screen = QGuiApplication::screenAt(cursor_pos);
183+
if (screen == nullptr) {
184+
screen = QGuiApplication::primaryScreen();
185+
}
186+
const QPoint anchored = screen_anchor_point(screen);
187+
if (!anchored.isNull()) {
188+
return anchored;
189+
}
190+
191+
return cursor_pos;
192+
}
193+
194+
QIcon icon_from_source(const QString &icon_source) {
195+
if (icon_source.isEmpty()) {
196+
return QIcon();
197+
}
198+
199+
const QFileInfo icon_fi(icon_source);
200+
if (icon_fi.exists()) {
201+
const QString file_path = icon_fi.absoluteFilePath();
202+
const QIcon file_icon(file_path);
203+
if (!file_icon.isNull()) {
204+
return file_icon;
147205
}
148-
if (avail.left() > full.left()) {
149-
// Panel on the left.
150-
return QPoint(avail.left(), avail.bottom());
206+
207+
const QPixmap pixmap(file_path);
208+
if (!pixmap.isNull()) {
209+
QIcon icon;
210+
icon.addPixmap(pixmap);
211+
return icon;
151212
}
152-
if (avail.right() < full.right()) {
153-
// Panel on the right.
154-
return QPoint(avail.right(), avail.bottom());
213+
}
214+
215+
const QIcon themed = QIcon::fromTheme(icon_source);
216+
if (!themed.isNull()) {
217+
return themed;
218+
}
219+
220+
return QIcon();
221+
}
222+
223+
QIcon resolve_tray_icon(const struct tray *tray_data) {
224+
if (tray_data == nullptr) {
225+
return QIcon();
226+
}
227+
228+
if (tray_data->icon != nullptr) {
229+
const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->icon));
230+
if (!icon.isNull()) {
231+
return icon;
155232
}
156233
}
157234

158-
return QCursor::pos();
235+
if (tray_data->iconPathCount > 0 && tray_data->iconPathCount < 64) {
236+
for (int i = 0; i < tray_data->iconPathCount; i++) {
237+
if (tray_data->allIconPaths[i] == nullptr) {
238+
continue;
239+
}
240+
const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->allIconPaths[i]));
241+
if (!icon.isNull()) {
242+
return icon;
243+
}
244+
}
245+
}
246+
247+
return QIcon();
248+
}
249+
250+
void popup_menu_for_activation(const QPoint &preferred_pos, int retries_left = 3) {
251+
if (g_tray_icon == nullptr) {
252+
return;
253+
}
254+
255+
QMenu *menu = g_tray_icon->contextMenu();
256+
if (menu == nullptr || menu->isVisible()) {
257+
return;
258+
}
259+
260+
menu->activateWindow();
261+
menu->setWindowFlag(Qt::Popup, true);
262+
menu->popup(calculateMenuPosition(preferred_pos));
263+
menu->setFocus(Qt::PopupFocusReason);
264+
265+
if (!menu->isVisible() && retries_left > 0) {
266+
QTimer::singleShot(30, g_tray_icon, [preferred_pos, retries_left]() {
267+
popup_menu_for_activation(preferred_pos, retries_left - 1);
268+
});
269+
}
159270
}
160271

161272
void close_notification() {
@@ -260,28 +371,43 @@ extern "C" {
260371
return -1;
261372
}
262373

374+
if (QCoreApplication::applicationName().isEmpty()) {
375+
QCoreApplication::setApplicationName(QStringLiteral("tray"));
376+
}
377+
if (QCoreApplication::applicationDisplayName().isEmpty()) {
378+
const QString display_name =
379+
(tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray");
380+
QCoreApplication::setApplicationDisplayName(display_name);
381+
}
382+
if (QGuiApplication::desktopFileName().isEmpty()) {
383+
QString desktop_name = QCoreApplication::applicationName();
384+
if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
385+
desktop_name += QStringLiteral(".desktop");
386+
}
387+
QGuiApplication::setDesktopFileName(desktop_name);
388+
}
389+
263390
// Show the context menu on left-click (Trigger).
264391
// Qt handles right-click natively via setContextMenu on both X11/XEmbed and
265392
// SNI (Wayland/AppIndicators), so we do not handle Context here.
266-
// The menu position is captured immediately before deferring to the next
267-
// event-loop iteration via QTimer::singleShot(0). Deferring allows any
393+
// The menu position is captured immediately before deferring by a short timer.
394+
// Deferring allows any
268395
// platform pointer grab from the tray click to be released before the menu
269396
// establishes its own grab.
270397
// activateWindow() gives the menu window X11 focus so that the subsequent
271398
// XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg.
272399
QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
273-
if (reason == QSystemTrayIcon::Trigger) {
274-
const QPoint pos = calculateMenuPosition();
275-
QTimer::singleShot(0, g_tray_icon, [pos]() {
276-
if (g_tray_icon != nullptr) {
277-
QMenu *menu = g_tray_icon->contextMenu();
278-
if (menu != nullptr && !menu->isVisible()) {
279-
menu->activateWindow();
280-
menu->popup(pos);
281-
}
282-
}
283-
});
400+
const bool left_click_activation =
401+
(reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
402+
403+
if (!left_click_activation) {
404+
return;
284405
}
406+
407+
const QPoint click_pos = QCursor::pos();
408+
QTimer::singleShot(30, g_tray_icon, [click_pos]() {
409+
popup_menu_for_activation(click_pos);
410+
});
285411
});
286412

287413
// Defer D-Bus ActionInvoked handler setup to the first event-loop iteration.
@@ -336,26 +462,18 @@ extern "C" {
336462
return;
337463
}
338464

339-
const QString icon_str = QString::fromUtf8(tray->icon);
340-
QIcon icon;
341-
const QFileInfo icon_fi(icon_str);
342-
if (icon_fi.exists()) {
343-
// Explicitly load via QPixmap so that the icon engine has pixmap data and
344-
// availableSizes() is populated immediately. QIcon(filename) lazy-loads the
345-
// pixmap, which leaves availableSizes() empty; Qt6's SNI tray backend then
346-
// sees no sizes and sends no icon data, causing the tray icon to be blank.
347-
const QPixmap pixmap(icon_fi.absoluteFilePath());
348-
if (!pixmap.isNull()) {
349-
icon.addPixmap(pixmap);
350-
}
351-
} else {
352-
icon = QIcon::fromTheme(icon_str);
465+
QIcon tray_icon = resolve_tray_icon(tray);
466+
if (tray_icon.isNull() && !g_tray_icon->icon().isNull()) {
467+
tray_icon = g_tray_icon->icon();
468+
}
469+
if (tray_icon.isNull()) {
470+
tray_icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
353471
}
354472
// Only update the icon when the resolved icon is valid. Setting a null icon
355473
// clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter
356474
// about QIcon::fromTheme when the name is not found in the active theme).
357-
if (!icon.isNull()) {
358-
g_tray_icon->setIcon(icon);
475+
if (!tray_icon.isNull()) {
476+
g_tray_icon->setIcon(tray_icon);
359477
}
360478

361479
if (tray->tooltip != nullptr) {
@@ -447,7 +565,7 @@ extern "C" {
447565
if (g_tray_icon != nullptr) {
448566
QMenu *menu = g_tray_icon->contextMenu();
449567
if (menu != nullptr) {
450-
menu->popup(calculateMenuPosition());
568+
popup_menu_for_activation(QPoint());
451569
QApplication::processEvents();
452570
}
453571
}

0 commit comments

Comments
 (0)