diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c14d84d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt index ae4cb42..1898ea2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,31 +1,87 @@ -cmake_minimum_required(VERSION 3.1) -project(tray VERSION 0.2 DESCRIPTION "A cross-platform C++ system tray library") +cmake_minimum_required(VERSION 3.14) -file(GLOB src - "tray/src/*.cpp" - "tray/src/*/*.cpp" - "tray/src/*/*/*.cpp" -) +# ────────────────────────────────────────────────────────────────────────────── +project(tray VERSION 0.2 DESCRIPTION "A cross-platform C++ system-tray library" + LANGUAGES CXX) + +# ─── source files ───────────────────────────────────────────────────────────── +# Everything under tray/src/ (one recursive glob keeps it readable) +file(GLOB_RECURSE src CONFIGURE_DEPENDS "tray/src/*.cpp") +# macOS uses Objective-C++ (.mm files) +if(APPLE) + file(GLOB_RECURSE src_mm CONFIGURE_DEPENDS "tray/src/*.mm") + list(APPEND src ${src_mm}) +endif() add_library(tray STATIC ${src}) -if (UNIX) +# ─── public headers ─────────────────────────────────────────────────────────── +target_include_directories(tray + PUBLIC + $ + $) + +# ─── platform-specific dependencies ─────────────────────────────────────────── +if(UNIX AND NOT APPLE) find_package(PkgConfig REQUIRED) - pkg_check_modules(GTK3 REQUIRED gtk+-3.0) - pkg_check_modules(APPINDICATOR REQUIRED appindicator3-0.1) - target_link_libraries(tray INTERFACE ${GTK3_LIBRARIES} ${APPINDICATOR_LIBRARIES}) - target_compile_options(tray PRIVATE -Wall -Wextra -Werror -pedantic -Wno-unused-lambda-capture) - target_include_directories(tray SYSTEM PUBLIC ${GTK3_INCLUDE_DIRS} ${APPINDICATOR_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}) -endif() + # Ask pkg-config to give us IMPORTED targets that already carry their flags + pkg_check_modules(GTK3 REQUIRED IMPORTED_TARGET gtk+-3.0) + pkg_check_modules(APPINDICATOR REQUIRED IMPORTED_TARGET ayatana-appindicator3-0.1) + pkg_check_modules(CAIRO REQUIRED IMPORTED_TARGET cairo) + + # Link – using the IMPORTED targets automatically propagates include paths, + # link libs (-lgtk-3, -lgdk-3, -lcairo, …) and extra compile flags (-pthread) + target_link_libraries(tray + PUBLIC # make the deps visible to consumers + PkgConfig::GTK3 + PkgConfig::APPINDICATOR + PkgConfig::CAIRO) + + # Extra warnings (but **no -Werror** on external code) + if(CMAKE_CXX_COMPILER_ID MATCHES "^(GNU|Clang)$") + target_compile_options(tray PRIVATE -Wall -Wextra -pedantic) + endif() + + # Only add -Wno-unused-lambda-capture if the compiler supports it + include(CheckCXXCompilerFlag) + check_cxx_compiler_flag("-Wno-unused-lambda-capture" HAS_WNO_UNUSED_LAMBDA) + if(HAS_WNO_UNUSED_LAMBDA) + target_compile_options(tray PRIVATE -Wno-unused-lambda-capture) + endif() +elseif(APPLE) + # macOS: Enable Objective-C++ for .mm files + enable_language(OBJCXX) + set_source_files_properties(${src_mm} PROPERTIES + COMPILE_FLAGS "-x objective-c++") -target_include_directories(tray SYSTEM PUBLIC "tray/include") + # Find and link Cocoa framework + find_library(COCOA_LIBRARY Cocoa REQUIRED) + target_link_libraries(tray PUBLIC ${COCOA_LIBRARY}) + # Extra warnings + if(CMAKE_CXX_COMPILER_ID MATCHES "^(GNU|Clang)$") + target_compile_options(tray PRIVATE -Wall -Wextra -pedantic) + endif() +endif() + +# ─── language & misc properties ─────────────────────────────────────────────── target_compile_features(tray PRIVATE cxx_std_17) -set_target_properties(tray PROPERTIES - CXX_STANDARD 17 - CXX_EXTENSIONS OFF - CXX_STANDARD_REQUIRED ON) -set_target_properties(tray PROPERTIES VERSION ${PROJECT_VERSION}) -set_target_properties(tray PROPERTIES PROJECT_NAME ${PROJECT_NAME}) \ No newline at end of file +set_target_properties(tray PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO + VERSION ${PROJECT_VERSION}) + +# (Optional) install rules – comment out if you don’t need “make install” +install(TARGETS tray + EXPORT trayTargets + ARCHIVE DESTINATION lib + INCLUDES DESTINATION include) + +install(DIRECTORY tray/include/ DESTINATION include) + +export(EXPORT trayTargets + FILE "${CMAKE_CURRENT_BINARY_DIR}/trayTargets.cmake") + diff --git a/README.md b/README.md index bafa635..73d3b27 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ A cross-platform C++17 library that allows you to create simple tray menus. | -------- | -------------- | | Windows | WinAPI | | Linux | AppIndicator | +| macOS | NSStatusBar | ## Dependencies - Linux - libappindicator-gtk3 +- macOS + - Cocoa framework (included with macOS SDK) ## Basic Usage ```cpp @@ -29,7 +32,9 @@ int main() return 0; } ``` -> On Windows it is not necessary to pass an icon path as icon, you can also use an icon-resource or an existing HICON. +> **Platform-specific notes:** +> - **Windows**: You can pass an icon path, icon-resource, or an existing HICON +> - **macOS**: Icon should be a path to a PNG file or a system icon name (e.g., "NSStatusAvailable") ## Menu components ### Button @@ -44,11 +49,13 @@ Button(std::string text, std::function callback); ImageButton(std::string text, Image image, std::function callback); ``` **Parameters:** -- `image` - The image tho show +- `image` - The image to show - Windows > Image should either be a path to a bitmap or an HBITMAP - Linux - > Image should either be a path to a png or a GtkImage + > Image should either be a path to a PNG or a GtkImage + - macOS + > Image should be a path to a PNG file or NSImage pointer - `callback` - The function that is called when the button is pressed ---- ### Toggle diff --git a/example/simple/CMakeLists.txt b/example/simple/CMakeLists.txt index b4ca6d9..081e7f1 100755 --- a/example/simple/CMakeLists.txt +++ b/example/simple/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.1) +cmake_minimum_required(VERSION 3.14) project(tray-example VERSION 0.1) add_executable(tray-example "main.cpp") diff --git a/tray/include/core/icon.hpp b/tray/include/core/icon.hpp index 8184c11..eba2923 100755 --- a/tray/include/core/icon.hpp +++ b/tray/include/core/icon.hpp @@ -29,5 +29,17 @@ namespace Tray operator HICON(); }; +#elif defined(__APPLE__) + class Icon + { + void *nsImage; // NSImage* + + public: + ~Icon(); + Icon(const char *path); + Icon(const std::string &path); + + operator void*(); + }; #endif } // namespace Tray \ No newline at end of file diff --git a/tray/include/core/image.hpp b/tray/include/core/image.hpp index 7526987..3ce1475 100755 --- a/tray/include/core/image.hpp +++ b/tray/include/core/image.hpp @@ -34,5 +34,18 @@ namespace Tray operator HBITMAP(); }; +#elif defined(__APPLE__) + class Image + { + void *nsImage; // NSImage* + + public: + ~Image(); + Image(void *image); + Image(const char *path); + Image(const std::string &path); + + operator void*(); + }; #endif } // namespace Tray \ No newline at end of file diff --git a/tray/include/core/linux/tray.hpp b/tray/include/core/linux/tray.hpp index 6436ce6..a94bbd2 100755 --- a/tray/include/core/linux/tray.hpp +++ b/tray/include/core/linux/tray.hpp @@ -1,7 +1,7 @@ #pragma once #if defined(__linux__) #include -#include +#include namespace Tray { @@ -22,6 +22,7 @@ namespace Tray void run() override; void exit() override; void update() override; + void pump() override; }; } // namespace Tray -#endif \ No newline at end of file +#endif diff --git a/tray/include/core/macos/tray.hpp b/tray/include/core/macos/tray.hpp new file mode 100644 index 0000000..e40b87c --- /dev/null +++ b/tray/include/core/macos/tray.hpp @@ -0,0 +1,44 @@ +#pragma once +#if defined(__APPLE__) +#include + +// Forward declarations to avoid Objective-C in header +#ifdef __OBJC__ +@class NSStatusItem; +@class NSMenu; +@class NSMenuItem; +@class TrayDelegate; +#else +typedef void NSStatusItem; +typedef void NSMenu; +typedef void NSMenuItem; +typedef void TrayDelegate; +#endif + +namespace Tray +{ + class Tray : public BaseTray + { + NSStatusItem *statusItem; + NSMenu *menu; + TrayDelegate *delegate; + + static void constructIntoMenu(NSMenu *menu, const std::vector> &, Tray *parent); + static NSMenu *construct(const std::vector> &, Tray *parent); + static void menuItemClicked(void *context); + + public: + ~Tray(); + Tray(std::string identifier, Icon icon); + template Tray(std::string identifier, Icon icon, const T &...entries) : Tray(identifier, icon) + { + addEntries(entries...); + } + + void run() override; + void exit() override; + void update() override; + void pump() override; + }; +} // namespace Tray +#endif diff --git a/tray/include/core/traybase.hpp b/tray/include/core/traybase.hpp index 8583afa..247bed6 100755 --- a/tray/include/core/traybase.hpp +++ b/tray/include/core/traybase.hpp @@ -37,6 +37,7 @@ namespace Tray virtual void run() = 0; virtual void exit() = 0; virtual void update() = 0; + virtual void pump() = 0; std::vector> getEntries(); }; } // namespace Tray \ No newline at end of file diff --git a/tray/include/core/windows/tray.hpp b/tray/include/core/windows/tray.hpp index ab59499..c2a6eb5 100755 --- a/tray/include/core/windows/tray.hpp +++ b/tray/include/core/windows/tray.hpp @@ -30,6 +30,7 @@ namespace Tray void run() override; void exit() override; void update() override; + void pump() override; }; } // namespace Tray #endif \ No newline at end of file diff --git a/tray/include/tray.hpp b/tray/include/tray.hpp index e83f80d..227b8ca 100755 --- a/tray/include/tray.hpp +++ b/tray/include/tray.hpp @@ -11,4 +11,6 @@ #include #elif defined(__linux__) #include +#elif defined(__APPLE__) +#include #endif \ No newline at end of file diff --git a/tray/src/core/linux/tray.cpp b/tray/src/core/linux/tray.cpp index 975c8d6..8721f61 100755 --- a/tray/src/core/linux/tray.cpp +++ b/tray/src/core/linux/tray.cpp @@ -1,6 +1,6 @@ #if defined(__linux__) #include -#include +#include #include #include @@ -20,6 +20,20 @@ Tray::Tray::Tray(std::string identifier, Icon icon) : BaseTray(std::move(identif } appIndicator = app_indicator_new(this->identifier.c_str(), this->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); + +#if 0 + gchar *current_dir = g_get_current_dir(); + g_print("%s current_dir:%s\n", G_STRFUNC, current_dir); + gchar *theme_path = g_build_path(G_DIR_SEPARATOR_S, + current_dir, + "data", + NULL); + g_print("%s path:%s\n", G_STRFUNC, theme_path); + app_indicator_set_icon_theme_path(appIndicator, theme_path); + g_free(current_dir); + g_free(theme_path); +#endif + app_indicator_set_status(appIndicator, APP_INDICATOR_STATUS_ACTIVE); } @@ -168,4 +182,9 @@ void Tray::Tray::run() } } -#endif \ No newline at end of file +void Tray::Tray::pump() +{ + gtk_main_iteration_do(false); +} + +#endif diff --git a/tray/src/core/macos/icon.mm b/tray/src/core/macos/icon.mm new file mode 100644 index 0000000..eeffa54 --- /dev/null +++ b/tray/src/core/macos/icon.mm @@ -0,0 +1,63 @@ +#if defined(__APPLE__) +#import +#include + +Tray::Icon::Icon(const std::string &path) +{ + NSString *nsPath = [NSString stringWithUTF8String:path.c_str()]; + NSImage *image = nil; + + // If path is not empty, try to load it + if (!path.empty()) { + // Try loading from file + image = [[NSImage alloc] initWithContentsOfFile:nsPath]; + + if (!image) { + // If file loading fails, try loading from system resources + image = [NSImage imageNamed:nsPath]; + if (image) { + image = [image copy]; // Make a copy to own it + } + } + } + + // If we have an image, set it up + if (image) { + [image setSize:NSMakeSize(18.0, 18.0)]; + // Manually retain since we're not using ARC + [image retain]; + nsImage = (__bridge void*)image; + } else { + // Create a simple default icon using a more reliable approach + NSImage *defaultIcon = [[NSImage alloc] initWithSize:NSMakeSize(18.0, 18.0)]; + + // Use a block-based drawing approach which is more reliable + [defaultIcon lockFocus]; + // Clear with transparency first + [[NSColor clearColor] setFill]; + NSRectFill(NSMakeRect(0, 0, 18, 18)); + // Draw a small filled circle + [[NSColor blackColor] setFill]; + [[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(5, 5, 8, 8)] fill]; + [defaultIcon unlockFocus]; + + // defaultIcon is already retained by alloc, just bridge it + nsImage = (__bridge void*)defaultIcon; + } +} + +Tray::Icon::Icon(const char *path) : Icon(std::string(path)) {} + +Tray::Icon::operator void*() +{ + return nsImage; +} + +Tray::Icon::~Icon() +{ + if (nsImage) { + CFRelease(nsImage); + } +} + +#endif diff --git a/tray/src/core/macos/image.mm b/tray/src/core/macos/image.mm new file mode 100644 index 0000000..84876bb --- /dev/null +++ b/tray/src/core/macos/image.mm @@ -0,0 +1,43 @@ +#if defined(__APPLE__) +#import +#include + +Tray::Image::Image(void *image) : nsImage(image) {} + +Tray::Image::Image(const char *path) : Image(std::string(path)) {} + +Tray::Image::Image(const std::string &path) +{ + NSString *nsPath = [NSString stringWithUTF8String:path.c_str()]; + NSImage *image = [[NSImage alloc] initWithContentsOfFile:nsPath]; + + if (image) { + // Resize to standard menu item icon size (16x16 points) + [image setSize:NSMakeSize(16.0, 16.0)]; + nsImage = (__bridge_retained void*)image; + } else { + // Try loading from system resources + image = [NSImage imageNamed:nsPath]; + if (image) { + image = [image copy]; // Make a copy to own it + [image setSize:NSMakeSize(16.0, 16.0)]; + nsImage = (__bridge_retained void*)image; + } else { + nsImage = nullptr; + } + } +} + +Tray::Image::operator void*() +{ + return nsImage; +} + +Tray::Image::~Image() +{ + if (nsImage) { + CFRelease(nsImage); + } +} + +#endif diff --git a/tray/src/core/macos/tray.mm b/tray/src/core/macos/tray.mm new file mode 100644 index 0000000..aec4f74 --- /dev/null +++ b/tray/src/core/macos/tray.mm @@ -0,0 +1,289 @@ +#if defined(__APPLE__) +#import +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Objective-C delegate class to bridge menu item clicks to C++ +@interface TrayDelegate : NSObject +{ + @public + Tray::Tray *tray; +} +- (void)menuItemClicked:(id)sender; +@end + +@implementation TrayDelegate +- (void)menuItemClicked:(id)sender +{ + NSMenuItem *menuItem = (NSMenuItem *)sender; + NSValue *value = (NSValue *)menuItem.representedObject; + Tray::TrayEntry *item = (Tray::TrayEntry *)[value pointerValue]; + + if (auto *button = dynamic_cast(item); button) + { + button->clicked(); + } + else if (auto *toggle = dynamic_cast(item); toggle) + { + toggle->onToggled(); + if (tray) + { + tray->update(); + } + } + else if (auto *syncedToggle = dynamic_cast(item); syncedToggle) + { + syncedToggle->onToggled(); + if (tray) + { + tray->update(); + } + } +} +@end + +Tray::Tray::Tray(std::string identifier, Icon icon) : BaseTray(std::move(identifier), icon) +{ + @autoreleasepool { + // Initialize NSApplication if not already done + NSApp = [NSApplication sharedApplication]; + + // Set activation policy to accessory so app doesn't appear in dock + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + + // Finish launching if not already done + if (![NSApp isRunning]) { + [NSApp finishLaunching]; + } + + // Create status bar item + statusItem = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength] retain]; + if (!statusItem) + { + throw std::runtime_error("Failed to create status item"); + } + + // Set the icon + NSImage *nsIcon = (__bridge NSImage *)static_cast(this->icon); + if (nsIcon && [nsIcon isKindOfClass:[NSImage class]]) + { + // Set the icon as a template so it adapts to light/dark mode + [nsIcon setTemplate:YES]; + statusItem.button.image = nsIcon; + } else { + // Even without an icon, set a visible title so the item appears + statusItem.button.title = @"⚫"; + } + + // Create menu + menu = [[NSMenu alloc] init]; + menu.autoenablesItems = NO; // We'll manage enabled state manually + + // Create delegate for callbacks + delegate = [[TrayDelegate alloc] init]; + delegate->tray = this; + } +} + +Tray::Tray::~Tray() +{ + @autoreleasepool { + if (statusItem) + { + [[NSStatusBar systemStatusBar] removeStatusItem:statusItem]; + [statusItem release]; + statusItem = nullptr; + } + + if (menu) + { + [menu release]; + menu = nullptr; + } + + if (delegate) + { + [delegate release]; + delegate = nullptr; + } + } +} + +void Tray::Tray::exit() +{ + @autoreleasepool { + if (statusItem) + { + [[NSStatusBar systemStatusBar] removeStatusItem:statusItem]; + [statusItem release]; + statusItem = nullptr; + } + + [NSApp stop:nil]; + + // Post a dummy event to wake up the event loop + NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined + location:NSMakePoint(0, 0) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + subtype:0 + data1:0 + data2:0]; + [NSApp postEvent:event atStart:YES]; + } +} + +void Tray::Tray::update() +{ + @autoreleasepool { + // Don't update if not fully initialized + if (!statusItem || !menu || !delegate) { + return; + } + + // Clear existing menu items + [menu removeAllItems]; + + // Reconstruct menu items directly into the existing menu + constructIntoMenu(menu, entries, this); + + // Set the menu on the status item + statusItem.menu = menu; + } +} + +// Helper to construct menu items into an existing menu +void Tray::Tray::constructIntoMenu(NSMenu *nsMenu, const std::vector> &entries, Tray *parent) +{ + @autoreleasepool { + for (const auto &entry : entries) + { + auto *item = entry.get(); + NSMenuItem *nsItem = nil; + + if (auto *toggle = dynamic_cast(item); toggle) + { + nsItem = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:toggle->getText().c_str()] + action:@selector(menuItemClicked:) + keyEquivalent:@""]; + nsItem.target = parent->delegate; + nsItem.state = toggle->isToggled() ? NSControlStateValueOn : NSControlStateValueOff; + nsItem.representedObject = [NSValue valueWithPointer:item]; + } + else if (auto *syncedToggle = dynamic_cast(item); syncedToggle) + { + nsItem = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:syncedToggle->getText().c_str()] + action:@selector(menuItemClicked:) + keyEquivalent:@""]; + nsItem.target = parent->delegate; + nsItem.state = syncedToggle->isToggled() ? NSControlStateValueOn : NSControlStateValueOff; + nsItem.representedObject = [NSValue valueWithPointer:item]; + } + else if (auto *submenu = dynamic_cast(item); submenu) + { + nsItem = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:submenu->getText().c_str()] + action:nil + keyEquivalent:@""]; + NSMenu *subMenu = construct(submenu->getEntries(), parent); + nsItem.submenu = subMenu; + } + else if (auto *imageButton = dynamic_cast(item); imageButton) + { + nsItem = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:imageButton->getText().c_str()] + action:@selector(menuItemClicked:) + keyEquivalent:@""]; + nsItem.target = parent->delegate; + nsItem.representedObject = [NSValue valueWithPointer:item]; + + NSImage *nsImage = (__bridge NSImage *)static_cast(imageButton->getImage()); + if (nsImage) + { + nsItem.image = nsImage; + } + } + else if (dynamic_cast