Files
crossdesk/src/gui/platform/tray/linux_tray.cpp
T
2026-07-21 16:42:13 +08:00

1048 lines
32 KiB
C++

#include "linux_tray.h"
#if defined(__linux__) && !defined(__APPLE__)
#include <SDL3/SDL.h>
#include <X11/Xatom.h>
#include <X11/Xft/Xft.h>
#include <X11/Xlib.h>
#include <X11/Xutil.h>
#include <X11/extensions/shape.h>
#include <X11/extensions/Xrender.h>
#include <algorithm>
#include <chrono>
#include <clocale>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "localization.h"
#include "rd_log.h"
#include "stb_image.h"
namespace crossdesk {
namespace {
constexpr int kTrayIconSize = 24;
constexpr int kMenuHeight = 28;
constexpr int kMenuHorizontalPadding = 12;
constexpr int kDockTimeoutMs = 800;
constexpr int kMaxTrayEventsPerTick = 32;
constexpr int kMaxTrayEventsDuringEmbed = 16;
constexpr long kSystemTrayRequestDock = 0;
constexpr long kXEmbedMapped = 1;
constexpr long kXEmbedEmbeddedNotify = 0;
bool IsAsciiPrintable(const std::string& text) {
for (unsigned char ch : text) {
if (ch < 0x20 || ch > 0x7e) {
return false;
}
}
return true;
}
std::string GetMenuLabel(int language_index) {
const int normalized_index = localization::detail::ClampLanguageIndex(
language_index);
const std::string& label = localization::exit_program[normalized_index];
return label.empty() ? "Exit" : label;
}
unsigned long AllocateColor(Display* display, int screen, const char* name,
unsigned long fallback) {
if (!display || !name) {
return fallback;
}
XColor parsed{};
XColor exact{};
Colormap colormap = DefaultColormap(display, screen);
if (XAllocNamedColor(display, colormap, name, &parsed, &exact)) {
return parsed.pixel;
}
return fallback;
}
std::vector<std::filesystem::path> BuildIconCandidatePaths() {
std::vector<std::filesystem::path> paths = {
"icons/linux/crossdesk_32x32.png",
"icons/linux/crossdesk_24x24.png",
"icons/linux/crossdesk_48x48.png",
"icons/linux/crossdesk_64x64.png",
"icons/linux/crossdesk_96x96.png",
"icons/linux/crossdesk_128x128.png",
"icons/linux/crossdesk_256x256.png",
"icons/linux/crossdesk_512x512.png",
"/usr/share/icons/hicolor/32x32/apps/crossdesk.png",
"/usr/share/icons/hicolor/24x24/apps/crossdesk.png",
"/usr/share/icons/hicolor/48x48/apps/crossdesk.png",
"/usr/share/icons/hicolor/64x64/apps/crossdesk.png",
"/usr/share/icons/hicolor/96x96/apps/crossdesk.png",
"/usr/share/icons/hicolor/128x128/apps/crossdesk.png",
"/usr/share/icons/hicolor/512x512/apps/crossdesk.png",
"/usr/share/icons/hicolor/256x256/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/32x32/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/24x24/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/48x48/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/64x64/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/96x96/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/128x128/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/512x512/apps/crossdesk.png",
"/usr/local/share/icons/hicolor/256x256/apps/crossdesk.png"};
const char* base_path = SDL_GetBasePath();
if (base_path && base_path[0] != '\0') {
const std::filesystem::path base_dir(base_path);
for (const char* size :
{"32", "24", "48", "64", "96", "128", "256", "512", "16"}) {
paths.push_back(base_dir /
("icons/linux/crossdesk_" + std::string(size) + "x" +
size + ".png"));
paths.push_back(base_dir /
("../share/icons/hicolor/" + std::string(size) + "x" +
size + "/apps/crossdesk.png"));
}
}
return paths;
}
int CountMaskBits(unsigned long mask) {
int count = 0;
while (mask) {
count += static_cast<int>(mask & 1UL);
mask >>= 1;
}
return count;
}
int CountMaskShift(unsigned long mask) {
int shift = 0;
while (mask && (mask & 1UL) == 0) {
++shift;
mask >>= 1;
}
return shift;
}
unsigned long ScaleColorToMask(unsigned char value, unsigned long mask) {
const int bits = CountMaskBits(mask);
if (bits <= 0) {
return 0;
}
const unsigned long max_value = (1UL << bits) - 1;
const unsigned long scaled =
(static_cast<unsigned long>(value) * max_value + 127UL) / 255UL;
return (scaled << CountMaskShift(mask)) & mask;
}
unsigned char SampleBilinearChannel(const std::vector<unsigned char>& rgba,
int width, int height, double src_x,
double src_y, int channel) {
src_x = std::clamp(src_x, 0.0, static_cast<double>(width - 1));
src_y = std::clamp(src_y, 0.0, static_cast<double>(height - 1));
const int x0 = static_cast<int>(std::floor(src_x));
const int y0 = static_cast<int>(std::floor(src_y));
const int x1 = std::min(x0 + 1, width - 1);
const int y1 = std::min(y0 + 1, height - 1);
const double fx = src_x - x0;
const double fy = src_y - y0;
auto channel_at = [&](int x, int y) -> double {
const size_t offset =
(static_cast<size_t>(y) * static_cast<size_t>(width) +
static_cast<size_t>(x)) *
4 +
static_cast<size_t>(channel);
return rgba[offset];
};
const double top =
channel_at(x0, y0) * (1.0 - fx) + channel_at(x1, y0) * fx;
const double bottom =
channel_at(x0, y1) * (1.0 - fx) + channel_at(x1, y1) * fx;
const double value = top * (1.0 - fy) + bottom * fy;
return static_cast<unsigned char>(std::clamp(value, 0.0, 255.0) + 0.5);
}
} // namespace
struct LinuxTrayImpl {
explicit LinuxTrayImpl(::SDL_Window* window, std::string tray_tooltip,
int language_index_value,
uint32_t tray_exit_event_type)
: app_window(window),
tooltip(std::move(tray_tooltip)),
language_index(language_index_value),
exit_event_type(tray_exit_event_type),
menu_label(GetMenuLabel(language_index_value)),
menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {}
explicit LinuxTrayImpl(std::function<void()> show_window_callback,
std::function<void()> hide_window_callback,
std::function<void()> exit_callback,
std::string tray_tooltip, int language_index_value)
: show_window(std::move(show_window_callback)),
hide_window(std::move(hide_window_callback)),
exit_app(std::move(exit_callback)),
tooltip(std::move(tray_tooltip)),
language_index(language_index_value),
menu_label(GetMenuLabel(language_index_value)),
menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {}
~LinuxTrayImpl() { RemoveTrayIcon(); }
bool MinimizeToTray() {
if (!EnsureTrayIcon()) {
return false;
}
if (hide_window) {
hide_window();
} else if (app_window) {
SDL_HideWindow(app_window);
}
return true;
}
void RemoveTrayIcon() {
HideMenu();
if (display && icon_window) {
XUnmapWindow(display, icon_window);
XDestroyWindow(display, icon_window);
icon_window = 0;
}
if (icon_colormap_owned && display && icon_colormap) {
XFreeColormap(display, icon_colormap);
icon_colormap = 0;
icon_colormap_owned = false;
}
if (font_set) {
XFreeFontSet(display, font_set);
font_set = nullptr;
}
if (fallback_font) {
XFreeFont(display, fallback_font);
fallback_font = nullptr;
}
if (menu_font) {
XftFontClose(display, menu_font);
menu_font = nullptr;
}
if (menu_text_color_allocated) {
XftColorFree(display, DefaultVisual(display, screen),
DefaultColormap(display, screen), &menu_text_color);
menu_text_color_allocated = false;
}
if (display) {
XCloseDisplay(display);
display = nullptr;
}
docked = false;
embedded = false;
icon_needs_redraw = false;
tray_manager_window = 0;
}
void ProcessEvents() { ProcessPendingEvents(kMaxTrayEventsPerTick); }
private:
int ProcessPendingEvents(int max_events) {
if (!display) {
return 0;
}
const int event_limit = std::max(1, max_events);
int processed = 0;
while (processed < event_limit && XPending(display) > 0) {
XEvent event{};
XNextEvent(display, &event);
HandleEvent(event);
++processed;
}
if (icon_needs_redraw && display && icon_window) {
icon_needs_redraw = false;
DrawIcon();
}
return processed;
}
void MarkIconDirty() { icon_needs_redraw = true; }
bool EnsureDisplay() {
if (display) {
return true;
}
display = XOpenDisplay(nullptr);
if (!display) {
LOG_WARN("Linux tray unavailable: failed to open X11 display");
return false;
}
std::setlocale(LC_CTYPE, "");
screen = DefaultScreen(display);
root_window = RootWindow(display, screen);
black_pixel = BlackPixel(display, screen);
white_pixel = WhitePixel(display, screen);
icon_visual = DefaultVisual(display, screen);
icon_depth = DefaultDepth(display, screen);
icon_colormap = DefaultColormap(display, screen);
SelectArgbVisual();
brand_pixel = AllocateColor(display, screen, "#2563eb", black_pixel);
hover_pixel = AllocateColor(display, screen, "#e5e7eb", white_pixel);
int shape_event_base = 0;
int shape_error_base = 0;
shape_available =
XShapeQueryExtension(display, &shape_event_base, &shape_error_base);
char selection_name[64] = {};
std::snprintf(selection_name, sizeof(selection_name),
"_NET_SYSTEM_TRAY_S%d", screen);
selection_atom = XInternAtom(display, selection_name, False);
tray_opcode_atom = XInternAtom(display, "_NET_SYSTEM_TRAY_OPCODE", False);
xembed_atom = XInternAtom(display, "_XEMBED", False);
xembed_info_atom = XInternAtom(display, "_XEMBED_INFO", False);
utf8_string_atom = XInternAtom(display, "UTF8_STRING", False);
net_wm_name_atom = XInternAtom(display, "_NET_WM_NAME", False);
char** missing_charset_list = nullptr;
int missing_charset_count = 0;
char* default_string = nullptr;
font_set = XCreateFontSet(display,
"-*-*-medium-r-normal--14-*-*-*-*-*-*-*",
&missing_charset_list, &missing_charset_count,
&default_string);
if (missing_charset_list) {
XFreeStringList(missing_charset_list);
}
fallback_font = XLoadQueryFont(display, "fixed");
menu_font = XftFontOpenName(display, screen, "Sans-10");
menu_text_color_allocated =
XftColorAllocName(display, DefaultVisual(display, screen),
DefaultColormap(display, screen), "#111827",
&menu_text_color);
return true;
}
void SelectArgbVisual() {
XVisualInfo visual_template{};
visual_template.screen = screen;
int visual_count = 0;
XVisualInfo* visual_info =
XGetVisualInfo(display, VisualScreenMask, &visual_template,
&visual_count);
if (!visual_info) {
return;
}
for (int i = 0; i < visual_count; ++i) {
XRenderPictFormat* format =
XRenderFindVisualFormat(display, visual_info[i].visual);
if (!format || format->type != PictTypeDirect ||
format->direct.alphaMask == 0) {
continue;
}
Colormap colormap =
XCreateColormap(display, root_window, visual_info[i].visual,
AllocNone);
if (!colormap) {
continue;
}
icon_visual = visual_info[i].visual;
icon_depth = visual_info[i].depth;
icon_format = format;
icon_colormap = colormap;
icon_colormap_owned = true;
break;
}
XFree(visual_info);
}
bool EnsureTrayIcon() {
if (docked && icon_window) {
return true;
}
if (!EnsureDisplay() || !EnsureIconWindow()) {
return false;
}
tray_manager_window = XGetSelectionOwner(display, selection_atom);
if (!tray_manager_window) {
LOG_WARN("Linux tray unavailable: no X11 system tray manager");
return false;
}
XEvent event{};
event.xclient.type = ClientMessage;
event.xclient.window = tray_manager_window;
event.xclient.message_type = tray_opcode_atom;
event.xclient.format = 32;
event.xclient.data.l[0] = CurrentTime;
event.xclient.data.l[1] = kSystemTrayRequestDock;
event.xclient.data.l[2] = icon_window;
event.xclient.data.l[3] = 0;
event.xclient.data.l[4] = 0;
embedded = false;
XSendEvent(display, tray_manager_window, False, NoEventMask, &event);
XFlush(display);
if (!WaitForEmbed()) {
LOG_WARN("Linux tray unavailable: tray manager did not embed icon");
XUnmapWindow(display, icon_window);
XDestroyWindow(display, icon_window);
icon_window = 0;
tray_manager_window = 0;
return false;
}
docked = true;
LOG_INFO("Linux tray icon embedded");
XMapRaised(display, icon_window);
icon_needs_redraw = false;
DrawIcon();
return true;
}
bool EnsureIconWindow() {
if (icon_window) {
return true;
}
XSetWindowAttributes attrs{};
attrs.background_pixel = 0;
attrs.border_pixel = 0;
attrs.colormap = icon_colormap;
attrs.override_redirect = True;
attrs.event_mask =
ExposureMask | ButtonPressMask | ButtonReleaseMask | StructureNotifyMask;
icon_window = XCreateWindow(
display, root_window, 0, 0, kTrayIconSize, kTrayIconSize, 0,
icon_depth, InputOutput, icon_visual,
CWBackPixel | CWBorderPixel | CWColormap | CWOverrideRedirect |
CWEventMask,
&attrs);
if (!icon_window) {
LOG_WARN("Linux tray unavailable: failed to create icon window");
return false;
}
XSizeHints size_hints{};
size_hints.flags = PMinSize | PMaxSize | PBaseSize;
size_hints.min_width = kTrayIconSize;
size_hints.min_height = kTrayIconSize;
size_hints.max_width = kTrayIconSize;
size_hints.max_height = kTrayIconSize;
size_hints.base_width = kTrayIconSize;
size_hints.base_height = kTrayIconSize;
XSetWMNormalHints(display, icon_window, &size_hints);
XClassHint class_hint{};
class_hint.res_name = const_cast<char*>("crossdesk");
class_hint.res_class = const_cast<char*>("CrossDesk");
XSetClassHint(display, icon_window, &class_hint);
XStoreName(display, icon_window, tooltip.c_str());
if (utf8_string_atom && net_wm_name_atom) {
XChangeProperty(display, icon_window, net_wm_name_atom, utf8_string_atom,
8, PropModeReplace,
reinterpret_cast<const unsigned char*>(tooltip.c_str()),
static_cast<int>(tooltip.size()));
}
long xembed_info[2] = {0, kXEmbedMapped};
XChangeProperty(display, icon_window, xembed_info_atom, xembed_info_atom, 32,
PropModeReplace,
reinterpret_cast<const unsigned char*>(xembed_info), 2);
XWMHints wm_hints{};
wm_hints.flags = InputHint;
wm_hints.input = True;
XSetWMHints(display, icon_window, &wm_hints);
return true;
}
void HandleEvent(const XEvent& event) {
switch (event.type) {
case Expose:
if (event.xexpose.window == icon_window) {
MarkIconDirty();
} else if (event.xexpose.window == menu_window) {
DrawMenu();
}
break;
case ConfigureNotify:
if (event.xconfigure.window == icon_window) {
MarkIconDirty();
}
break;
case DestroyNotify:
if (event.xdestroywindow.window == icon_window) {
icon_window = 0;
docked = false;
embedded = false;
icon_needs_redraw = false;
} else if (event.xdestroywindow.window == menu_window) {
menu_window = 0;
menu_visible = false;
}
break;
case ReparentNotify:
if (event.xreparent.window == icon_window &&
event.xreparent.parent == tray_manager_window) {
embedded = true;
MarkIconDirty();
}
break;
case ClientMessage:
if (event.xclient.window == icon_window &&
event.xclient.format == 32 &&
event.xclient.message_type == xembed_atom &&
event.xclient.data.l[1] == kXEmbedEmbeddedNotify) {
embedded = true;
MarkIconDirty();
}
break;
case ButtonRelease:
HandleButtonRelease(event.xbutton);
break;
case LeaveNotify:
if (event.xcrossing.window == menu_window) {
HideMenu();
}
break;
default:
break;
}
}
void HandleButtonRelease(const XButtonEvent& event) {
if (menu_window && event.window == menu_window) {
const bool inside_menu =
event.x >= 0 && event.y >= 0 && event.x < menu_width &&
event.y < kMenuHeight;
const bool activate_exit = inside_menu && event.button == Button1;
HideMenu();
if (activate_exit) {
RequestExit();
}
return;
}
if (event.window != icon_window) {
if (menu_visible) {
HideMenu();
}
return;
}
if (event.button == Button1) {
ShowWindow();
} else if (event.button == Button3) {
ShowMenu(event.x_root, event.y_root);
}
}
bool WaitForEmbed() {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(kDockTimeoutMs);
while (std::chrono::steady_clock::now() < deadline) {
ProcessPendingEvents(kMaxTrayEventsDuringEmbed);
if (embedded) {
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return embedded;
}
void DrawIcon() {
if (!display || !icon_window) {
return;
}
XWindowAttributes attributes{};
if (!XGetWindowAttributes(display, icon_window, &attributes)) {
return;
}
const int width = std::max(1, attributes.width);
const int height = std::max(1, attributes.height);
if (DrawProgramIcon(width, height)) {
return;
}
const int side = std::min(width, height);
const int shape_x = (width - side) / 2;
const int shape_y = (height - side) / 2;
ApplyIconShape(shape_x, shape_y, side);
const int pad = std::max(2, side / 8);
const int box = std::max(1, side - pad * 2);
GC gc = XCreateGC(display, icon_window, 0, nullptr);
XSetForeground(display, gc, white_pixel);
XFillRectangle(display, icon_window, gc, 0, 0, width, height);
XSetForeground(display, gc, brand_pixel);
XFillArc(display, icon_window, gc, (width - box) / 2, (height - box) / 2,
box, box, 0, 360 * 64);
const char* mark = "C";
XSetForeground(display, gc, white_pixel);
if (fallback_font) {
XSetFont(display, gc, fallback_font->fid);
const int text_width = XTextWidth(fallback_font, mark, 1);
const int x = (width - text_width) / 2;
const int y = (height + fallback_font->ascent - fallback_font->descent) /
2;
XDrawString(display, icon_window, gc, x, y, mark, 1);
} else {
XDrawString(display, icon_window, gc, width / 2 - 3, height / 2 + 4,
mark, 1);
}
XFreeGC(display, gc);
XFlush(display);
}
bool DrawProgramIcon(int width, int height) {
if (!LoadProgramIcon()) {
return false;
}
const int image_size = std::min(width, height);
if (image_size <= 0) {
return false;
}
const int image_x = (width - image_size) / 2;
const int image_y = (height - image_size) / 2;
ApplyIconShape(image_x, image_y, image_size);
XImage* image = XCreateImage(display, icon_visual, icon_depth, ZPixmap, 0,
nullptr, width, height, 32, 0);
if (!image) {
return false;
}
if (image->red_mask == 0 || image->green_mask == 0 ||
image->blue_mask == 0) {
XDestroyImage(image);
return false;
}
image->data = static_cast<char*>(
std::calloc(static_cast<size_t>(image->bytes_per_line),
static_cast<size_t>(height)));
if (!image->data) {
XDestroyImage(image);
return false;
}
const unsigned long background_pixel = RgbaToPixel(image, 0, 0, 0, 0);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
XPutPixel(image, x, y, background_pixel);
}
}
Pixmap shape_mask = 0;
GC shape_gc = nullptr;
if (shape_available) {
shape_mask = XCreatePixmap(display, icon_window, width, height, 1);
if (shape_mask) {
shape_gc = XCreateGC(display, shape_mask, 0, nullptr);
XSetForeground(display, shape_gc, 0);
XFillRectangle(display, shape_mask, shape_gc, 0, 0, width, height);
XSetForeground(display, shape_gc, 1);
}
}
for (int y = 0; y < height; ++y) {
if (y < image_y || y >= image_y + image_size) {
continue;
}
const double src_y =
((static_cast<double>(y - image_y) + 0.5) * icon_height /
image_size) -
0.5;
for (int x = 0; x < width; ++x) {
if (x < image_x || x >= image_x + image_size) {
continue;
}
const double src_x =
((static_cast<double>(x - image_x) + 0.5) * icon_width /
image_size) -
0.5;
const unsigned char src_r =
SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x,
src_y, 0);
const unsigned char src_g =
SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x,
src_y, 1);
const unsigned char src_b =
SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x,
src_y, 2);
const unsigned char src_a =
SampleBilinearChannel(icon_rgba, icon_width, icon_height, src_x,
src_y, 3);
const unsigned long pixel =
RgbaToPixel(image, src_r, src_g, src_b, src_a);
XPutPixel(image, x, y, pixel);
if (shape_gc && src_a > 24) {
XDrawPoint(display, shape_mask, shape_gc, x, y);
}
}
}
if (shape_mask) {
XShapeCombineMask(display, icon_window, ShapeBounding, 0, 0, shape_mask,
ShapeSet);
XShapeCombineMask(display, icon_window, ShapeInput, 0, 0, shape_mask,
ShapeSet);
if (shape_gc) {
XFreeGC(display, shape_gc);
}
XFreePixmap(display, shape_mask);
}
GC gc = XCreateGC(display, icon_window, 0, nullptr);
XPutImage(display, icon_window, gc, image, 0, 0, 0, 0, width, height);
XFreeGC(display, gc);
XDestroyImage(image);
XFlush(display);
return true;
}
void ApplyIconShape(int x, int y, int size) {
if (!shape_available || !display || !icon_window || size <= 0) {
return;
}
XRectangle rect{};
rect.x = static_cast<short>(x);
rect.y = static_cast<short>(y);
rect.width = static_cast<unsigned short>(size);
rect.height = static_cast<unsigned short>(size);
XShapeCombineRectangles(display, icon_window, ShapeBounding, 0, 0, &rect, 1,
ShapeSet, Unsorted);
XShapeCombineRectangles(display, icon_window, ShapeInput, 0, 0, &rect, 1,
ShapeSet, Unsorted);
}
unsigned long RgbaToPixel(XImage* image, unsigned char r, unsigned char g,
unsigned char b, unsigned char a) const {
unsigned char out_r = r;
unsigned char out_g = g;
unsigned char out_b = b;
unsigned long alpha_mask = 0;
if (icon_format && icon_format->type == PictTypeDirect &&
icon_format->direct.alphaMask > 0) {
alpha_mask = ((1UL << icon_format->direct.alphaMask) - 1UL)
<< icon_format->direct.alpha;
out_r = static_cast<unsigned char>(
(static_cast<unsigned int>(r) * a + 127U) / 255U);
out_g = static_cast<unsigned char>(
(static_cast<unsigned int>(g) * a + 127U) / 255U);
out_b = static_cast<unsigned char>(
(static_cast<unsigned int>(b) * a + 127U) / 255U);
}
return ScaleColorToMask(out_r, image->red_mask) |
ScaleColorToMask(out_g, image->green_mask) |
ScaleColorToMask(out_b, image->blue_mask) |
ScaleColorToMask(a, alpha_mask);
}
bool LoadProgramIcon() {
if (!icon_rgba.empty()) {
return true;
}
for (const auto& candidate : BuildIconCandidatePaths()) {
if (!std::filesystem::exists(candidate)) {
continue;
}
int width = 0;
int height = 0;
int channels = 0;
unsigned char* pixels =
stbi_load(candidate.string().c_str(), &width, &height, &channels, 4);
if (!pixels || width <= 0 || height <= 0) {
if (pixels) {
stbi_image_free(pixels);
}
continue;
}
icon_width = width;
icon_height = height;
icon_rgba.assign(pixels, pixels + static_cast<size_t>(width) *
static_cast<size_t>(height) * 4);
stbi_image_free(pixels);
LOG_INFO("Loaded Linux tray icon: {}", candidate.string());
return true;
}
LOG_WARN("Linux tray icon not found, using fallback icon");
return false;
}
int MenuTextWidth() const {
if (menu_font) {
XGlyphInfo extents{};
XftTextExtentsUtf8(display, menu_font,
reinterpret_cast<const FcChar8*>(menu_label.c_str()),
static_cast<int>(menu_label.size()), &extents);
return extents.xOff;
}
if (font_set) {
XRectangle ink{};
XRectangle logical{};
Xutf8TextExtents(font_set, menu_label.c_str(),
static_cast<int>(menu_label.size()), &ink, &logical);
return logical.width;
}
if (fallback_font) {
return XTextWidth(fallback_font, menu_ascii_label.c_str(),
static_cast<int>(menu_ascii_label.size()));
}
return static_cast<int>(menu_ascii_label.size()) * 8;
}
void ShowMenu(int root_x, int root_y) {
if (!display) {
return;
}
HideMenu();
menu_width = std::max(72, MenuTextWidth() + kMenuHorizontalPadding * 2);
const int display_width = DisplayWidth(display, screen);
const int display_height = DisplayHeight(display, screen);
const int x = std::clamp(root_x, 0, std::max(0, display_width - menu_width));
const int y = std::clamp(root_y, 0,
std::max(0, display_height - kMenuHeight));
XSetWindowAttributes attrs{};
attrs.override_redirect = True;
attrs.background_pixel = white_pixel;
attrs.border_pixel = black_pixel;
attrs.event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask |
LeaveWindowMask;
menu_window = XCreateWindow(
display, root_window, x, y, menu_width, kMenuHeight, 1, CopyFromParent,
InputOutput, CopyFromParent,
CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &attrs);
if (!menu_window) {
return;
}
menu_visible = true;
XMapRaised(display, menu_window);
XGrabPointer(display, menu_window, False,
ButtonPressMask | ButtonReleaseMask, GrabModeAsync,
GrabModeAsync, None, None, CurrentTime);
DrawMenu();
XFlush(display);
}
void HideMenu() {
if (!display || !menu_window) {
menu_visible = false;
return;
}
XUngrabPointer(display, CurrentTime);
XDestroyWindow(display, menu_window);
menu_window = 0;
menu_visible = false;
XFlush(display);
}
void DrawMenu() {
if (!display || !menu_window) {
return;
}
GC gc = XCreateGC(display, menu_window, 0, nullptr);
XSetForeground(display, gc, white_pixel);
XFillRectangle(display, menu_window, gc, 0, 0, menu_width, kMenuHeight);
XSetForeground(display, gc, hover_pixel);
XFillRectangle(display, menu_window, gc, 1, 1, menu_width - 2,
kMenuHeight - 2);
XSetForeground(display, gc, black_pixel);
int baseline = kMenuHeight / 2 + 5;
if (menu_font && menu_text_color_allocated) {
XftDraw* draw = XftDrawCreate(display, menu_window,
DefaultVisual(display, screen),
DefaultColormap(display, screen));
if (draw) {
baseline = (kMenuHeight - (menu_font->ascent + menu_font->descent)) /
2 +
menu_font->ascent;
XftDrawStringUtf8(
draw, &menu_text_color, menu_font, kMenuHorizontalPadding, baseline,
reinterpret_cast<const FcChar8*>(menu_label.c_str()),
static_cast<int>(menu_label.size()));
XftDrawDestroy(draw);
}
} else if (font_set) {
XFontSetExtents* extents = XExtentsOfFontSet(font_set);
if (extents) {
baseline = (kMenuHeight - extents->max_logical_extent.height) / 2 -
extents->max_logical_extent.y;
}
Xutf8DrawString(display, menu_window, font_set, gc,
kMenuHorizontalPadding, baseline, menu_label.c_str(),
static_cast<int>(menu_label.size()));
} else {
if (fallback_font) {
XSetFont(display, gc, fallback_font->fid);
baseline = (kMenuHeight + fallback_font->ascent -
fallback_font->descent) /
2;
}
XDrawString(display, menu_window, gc, kMenuHorizontalPadding, baseline,
menu_ascii_label.c_str(),
static_cast<int>(menu_ascii_label.size()));
}
XFreeGC(display, gc);
XFlush(display);
}
void ShowWindow() {
if (show_window) {
show_window();
} else if (app_window) {
SDL_ShowWindow(app_window);
SDL_RestoreWindow(app_window);
SDL_RaiseWindow(app_window);
}
}
void RequestExit() {
if (exit_app) {
exit_app();
return;
}
SDL_Event event{};
event.type =
exit_event_type == 0 || exit_event_type == static_cast<uint32_t>(-1)
? SDL_EVENT_QUIT
: exit_event_type;
SDL_PushEvent(&event);
}
::SDL_Window* app_window = nullptr;
std::function<void()> show_window;
std::function<void()> hide_window;
std::function<void()> exit_app;
std::string tooltip;
int language_index = 0;
uint32_t exit_event_type = 0;
std::string menu_label;
std::string menu_ascii_label;
Display* display = nullptr;
int screen = 0;
::Window root_window = 0;
::Window tray_manager_window = 0;
::Window icon_window = 0;
::Window menu_window = 0;
Atom selection_atom = None;
Atom tray_opcode_atom = None;
Atom xembed_atom = None;
Atom xembed_info_atom = None;
Atom utf8_string_atom = None;
Atom net_wm_name_atom = None;
unsigned long black_pixel = 0;
unsigned long white_pixel = 0;
unsigned long brand_pixel = 0;
unsigned long hover_pixel = 0;
Visual* icon_visual = nullptr;
int icon_depth = 0;
Colormap icon_colormap = 0;
bool icon_colormap_owned = false;
XRenderPictFormat* icon_format = nullptr;
XFontSet font_set = nullptr;
XFontStruct* fallback_font = nullptr;
XftFont* menu_font = nullptr;
XftColor menu_text_color{};
bool menu_text_color_allocated = false;
std::vector<unsigned char> icon_rgba;
int icon_width = 0;
int icon_height = 0;
int menu_width = 72;
bool docked = false;
bool embedded = false;
bool menu_visible = false;
bool icon_needs_redraw = false;
bool shape_available = false;
};
LinuxTray::LinuxTray(::SDL_Window* app_window, const std::string& tooltip,
int language_index, uint32_t exit_event_type)
: impl_(std::make_unique<LinuxTrayImpl>(app_window, tooltip, language_index,
exit_event_type)) {}
LinuxTray::LinuxTray(std::function<void()> show_window,
std::function<void()> hide_window,
std::function<void()> exit_app,
const std::string& tooltip, int language_index)
: impl_(std::make_unique<LinuxTrayImpl>(
std::move(show_window), std::move(hide_window), std::move(exit_app),
tooltip, language_index)) {}
LinuxTray::~LinuxTray() = default;
bool LinuxTray::MinimizeToTray() { return impl_->MinimizeToTray(); }
void LinuxTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
void LinuxTray::ProcessEvents() { impl_->ProcessEvents(); }
} // namespace crossdesk
#endif // defined(__linux__) && !defined(__APPLE__)