diff --git a/NEWS b/NEWS index ff1d7eae..d2fc6a43 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,10 @@ +Version 0.10.0 +~~~~~~~~~~~~~ +Released: 2026-01-28 + +Features: + * [#711] Add macOS menu bar status item (NSStatusItem) with system tray icon. + Version 0.9.4 ~~~~~~~~~~~~~ Released: 2025-02-17 diff --git a/meson.build b/meson.build index ecd63778..f26caacf 100644 --- a/meson.build +++ b/meson.build @@ -17,8 +17,13 @@ endif if (cc.has_argument('-Wno-c99-designator')) add_project_arguments('-Wno-c99-designator', language : 'cpp') endif -if host_machine.system() == 'darwin' and get_option('werror') - add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'cpp') +if host_machine.system() == 'darwin' + add_languages('objcpp') + add_project_arguments('-DGLIB_VERSION_MIN_REQUIRED=GLIB_VERSION_2_70', language : 'objcpp') + if get_option('werror') + add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'cpp') + add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'objcpp') + endif endif so_version = 2 diff --git a/src/iptux/AppIndicatorMac.mm b/src/iptux/AppIndicatorMac.mm new file mode 100644 index 00000000..8704f6f5 --- /dev/null +++ b/src/iptux/AppIndicatorMac.mm @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "config.h" +#include "AppIndicator.h" + +#include +#include + +#import + +// Objective-C helper class for NSStatusItem callbacks. +// Must be at global scope (Objective-C declarations cannot appear inside a C++ +// namespace). +@interface IptuxStatusItemHelper : NSObject { + GActionGroup* actionGroup_; + iptux::IptuxAppIndicator* owner_; +} +- (instancetype)initWithActionGroup:(GActionGroup*)actionGroup + owner:(iptux::IptuxAppIndicator*)owner; +- (void)openMainWindow:(id)sender; +- (void)openPreferences:(id)sender; +- (void)quit:(id)sender; +- (void)statusItemClicked:(id)sender; +@end + +@implementation IptuxStatusItemHelper +- (instancetype)initWithActionGroup:(GActionGroup*)actionGroup + owner:(iptux::IptuxAppIndicator*)owner { + self = [super init]; + if (self) { + actionGroup_ = actionGroup; + owner_ = owner; + } + return self; +} + +- (void)openMainWindow:(id)sender { + (void)sender; + g_action_group_activate_action(actionGroup_, "open_main_window", NULL); +} + +- (void)openPreferences:(id)sender { + (void)sender; + g_action_group_activate_action(actionGroup_, "preferences", NULL); +} + +- (void)quit:(id)sender { + (void)sender; + g_action_group_activate_action(actionGroup_, "quit", NULL); +} + +- (void)statusItemClicked:(id)sender { + (void)sender; + owner_->sigActivateMainWindow.emit(); +} +@end + +static NSImage* pixbufToNSImage(GdkPixbuf* pixbuf) { + int width = gdk_pixbuf_get_width(pixbuf); + int height = gdk_pixbuf_get_height(pixbuf); + int stride = gdk_pixbuf_get_rowstride(pixbuf); + gboolean hasAlpha = gdk_pixbuf_get_has_alpha(pixbuf); + const guchar* pixels = gdk_pixbuf_get_pixels(pixbuf); + + int bitsPerSample = 8; + int channels = hasAlpha ? 4 : 3; + + NSBitmapImageRep* rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:width + pixelsHigh:height + bitsPerSample:bitsPerSample + samplesPerPixel:channels + hasAlpha:hasAlpha + isPlanar:NO + colorSpaceName:NSDeviceRGBColorSpace + bytesPerRow:stride + bitsPerPixel:bitsPerSample * channels]; + + memcpy([rep bitmapData], pixels, height * stride); + + NSImage* image = [[NSImage alloc] initWithSize:NSMakeSize(width, height)]; + [image addRepresentation:rep]; + [rep release]; + + return [image autorelease]; +} + +static NSImage* loadIcon(const char* iconName, int size) { + auto theme = gtk_icon_theme_get_default(); + GError* error = nullptr; + auto pixbuf = gtk_icon_theme_load_icon(theme, iconName, size, + GtkIconLookupFlags(0), &error); + if (!pixbuf) { + g_warning("Couldn't load icon %s: %s", iconName, error->message); + g_error_free(error); + return nil; + } + NSImage* image = pixbufToNSImage(pixbuf); + [image retain]; + g_object_unref(pixbuf); + + // Resize for menu bar (typically 18x18 points) + [image setSize:NSMakeSize(18, 18)]; + return image; +} + +namespace iptux { + +class IptuxAppIndicatorPrivate { + public: + IptuxAppIndicatorPrivate() {} + ~IptuxAppIndicatorPrivate() { + if (statusItem) { + [[NSStatusBar systemStatusBar] removeStatusItem:statusItem]; + [statusItem release]; + } + if (helper) { + [helper release]; + } + if (normalIcon) { + [normalIcon release]; + } + if (attentionIcon) { + [attentionIcon release]; + } + } + + NSStatusItem* statusItem = nil; + IptuxStatusItemHelper* helper = nil; + NSImage* normalIcon = nil; + NSImage* attentionIcon = nil; +}; + +IptuxAppIndicator::IptuxAppIndicator(GActionGroup* action_group) { + this->priv = std::make_shared(); + + priv->helper = [[IptuxStatusItemHelper alloc] initWithActionGroup:action_group + owner:this]; + + // Load icons + priv->normalIcon = loadIcon("iptux-icon", 64); + priv->attentionIcon = loadIcon("iptux-attention", 64); + + // Create status item + priv->statusItem = + [[[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength] retain]; + + if (priv->normalIcon) { + priv->statusItem.button.image = priv->normalIcon; + } + priv->statusItem.button.toolTip = @"Iptux"; + + // Build the menu + NSMenu* menu = [[NSMenu alloc] init]; + + NSMenuItem* openItem = + [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Open Iptux")] + action:@selector(openMainWindow:) + keyEquivalent:@""]; + openItem.target = priv->helper; + [menu addItem:openItem]; + [openItem release]; + + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem* prefsItem = + [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Preferences")] + action:@selector(openPreferences:) + keyEquivalent:@""]; + prefsItem.target = priv->helper; + [menu addItem:prefsItem]; + [prefsItem release]; + + NSMenuItem* quitItem = + [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Quit")] + action:@selector(quit:) + keyEquivalent:@""]; + quitItem.target = priv->helper; + [menu addItem:quitItem]; + [quitItem release]; + + priv->statusItem.menu = menu; + [menu release]; +} + +void IptuxAppIndicator::SetUnreadCount(int count) { + if (!priv->statusItem) return; + + if (count > 0 && priv->attentionIcon) { + priv->statusItem.button.image = priv->attentionIcon; + } else if (priv->normalIcon) { + priv->statusItem.button.image = priv->normalIcon; + } +} + +} // namespace iptux diff --git a/src/iptux/meson.build b/src/iptux/meson.build index a790d312..7dfd04d9 100644 --- a/src/iptux/meson.build +++ b/src/iptux/meson.build @@ -26,7 +26,10 @@ sources = files([ ]) dependencies = [gtk_dep, jsoncpp_dep, sigc_dep] -if appindicator_dep.found() +if host_machine.system() == 'darwin' + sources += ['AppIndicatorMac.mm'] + dependencies += [dependency('appleframeworks', modules: ['Cocoa'])] +elif appindicator_dep.found() sources += ['AppIndicator.cpp'] dependencies += [appindicator_dep] else