Skip to content

Commit be163bb

Browse files
authored
Add macOS menu bar status item (NSStatusItem) with system tray icon (#712)
## Summary by Sourcery Add native macOS menu bar status item implementation and integrate Objective-C++ support into the build. New Features: - Introduce a macOS-specific app indicator implementation using NSStatusItem with menu actions wired to existing GActionGroup commands. Enhancements: - Enable Objective-C++ compilation and Cocoa framework integration on macOS in the Meson build, including GLib version requirement configuration and consistent assertion flags for C++ and Objective-C++ sources.
1 parent 8dbae1a commit be163bb

4 files changed

Lines changed: 215 additions & 3 deletions

File tree

NEWS

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
Version 0.10.0
2+
~~~~~~~~~~~~~
3+
Released: 2026-01-28
4+
5+
Features:
6+
* [#711] Add macOS menu bar status item (NSStatusItem) with system tray icon.
7+
18
Version 0.9.4
29
~~~~~~~~~~~~~
310
Released: 2025-02-17

meson.build

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ endif
1717
if (cc.has_argument('-Wno-c99-designator'))
1818
add_project_arguments('-Wno-c99-designator', language : 'cpp')
1919
endif
20-
if host_machine.system() == 'darwin' and get_option('werror')
21-
add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'cpp')
20+
if host_machine.system() == 'darwin'
21+
add_languages('objcpp')
22+
add_project_arguments('-DGLIB_VERSION_MIN_REQUIRED=GLIB_VERSION_2_70', language : 'objcpp')
23+
if get_option('werror')
24+
add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'cpp')
25+
add_project_arguments('-U_LIBCPP_ENABLE_ASSERTIONS', language: 'objcpp')
26+
endif
2227
endif
2328

2429
so_version = 2

src/iptux/AppIndicatorMac.mm

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
3+
#include "config.h"
4+
#include "AppIndicator.h"
5+
6+
#include <glib/gi18n.h>
7+
#include <gtk/gtk.h>
8+
9+
#import <Cocoa/Cocoa.h>
10+
11+
// Objective-C helper class for NSStatusItem callbacks.
12+
// Must be at global scope (Objective-C declarations cannot appear inside a C++
13+
// namespace).
14+
@interface IptuxStatusItemHelper : NSObject {
15+
GActionGroup* actionGroup_;
16+
iptux::IptuxAppIndicator* owner_;
17+
}
18+
- (instancetype)initWithActionGroup:(GActionGroup*)actionGroup
19+
owner:(iptux::IptuxAppIndicator*)owner;
20+
- (void)openMainWindow:(id)sender;
21+
- (void)openPreferences:(id)sender;
22+
- (void)quit:(id)sender;
23+
- (void)statusItemClicked:(id)sender;
24+
@end
25+
26+
@implementation IptuxStatusItemHelper
27+
- (instancetype)initWithActionGroup:(GActionGroup*)actionGroup
28+
owner:(iptux::IptuxAppIndicator*)owner {
29+
self = [super init];
30+
if (self) {
31+
actionGroup_ = actionGroup;
32+
owner_ = owner;
33+
}
34+
return self;
35+
}
36+
37+
- (void)openMainWindow:(id)sender {
38+
(void)sender;
39+
g_action_group_activate_action(actionGroup_, "open_main_window", NULL);
40+
}
41+
42+
- (void)openPreferences:(id)sender {
43+
(void)sender;
44+
g_action_group_activate_action(actionGroup_, "preferences", NULL);
45+
}
46+
47+
- (void)quit:(id)sender {
48+
(void)sender;
49+
g_action_group_activate_action(actionGroup_, "quit", NULL);
50+
}
51+
52+
- (void)statusItemClicked:(id)sender {
53+
(void)sender;
54+
owner_->sigActivateMainWindow.emit();
55+
}
56+
@end
57+
58+
static NSImage* pixbufToNSImage(GdkPixbuf* pixbuf) {
59+
int width = gdk_pixbuf_get_width(pixbuf);
60+
int height = gdk_pixbuf_get_height(pixbuf);
61+
int stride = gdk_pixbuf_get_rowstride(pixbuf);
62+
gboolean hasAlpha = gdk_pixbuf_get_has_alpha(pixbuf);
63+
const guchar* pixels = gdk_pixbuf_get_pixels(pixbuf);
64+
65+
int bitsPerSample = 8;
66+
int channels = hasAlpha ? 4 : 3;
67+
68+
NSBitmapImageRep* rep = [[NSBitmapImageRep alloc]
69+
initWithBitmapDataPlanes:NULL
70+
pixelsWide:width
71+
pixelsHigh:height
72+
bitsPerSample:bitsPerSample
73+
samplesPerPixel:channels
74+
hasAlpha:hasAlpha
75+
isPlanar:NO
76+
colorSpaceName:NSDeviceRGBColorSpace
77+
bytesPerRow:stride
78+
bitsPerPixel:bitsPerSample * channels];
79+
80+
memcpy([rep bitmapData], pixels, height * stride);
81+
82+
NSImage* image = [[NSImage alloc] initWithSize:NSMakeSize(width, height)];
83+
[image addRepresentation:rep];
84+
[rep release];
85+
86+
return [image autorelease];
87+
}
88+
89+
static NSImage* loadIcon(const char* iconName, int size) {
90+
auto theme = gtk_icon_theme_get_default();
91+
GError* error = nullptr;
92+
auto pixbuf = gtk_icon_theme_load_icon(theme, iconName, size,
93+
GtkIconLookupFlags(0), &error);
94+
if (!pixbuf) {
95+
g_warning("Couldn't load icon %s: %s", iconName, error->message);
96+
g_error_free(error);
97+
return nil;
98+
}
99+
NSImage* image = pixbufToNSImage(pixbuf);
100+
[image retain];
101+
g_object_unref(pixbuf);
102+
103+
// Resize for menu bar (typically 18x18 points)
104+
[image setSize:NSMakeSize(18, 18)];
105+
return image;
106+
}
107+
108+
namespace iptux {
109+
110+
class IptuxAppIndicatorPrivate {
111+
public:
112+
IptuxAppIndicatorPrivate() {}
113+
~IptuxAppIndicatorPrivate() {
114+
if (statusItem) {
115+
[[NSStatusBar systemStatusBar] removeStatusItem:statusItem];
116+
[statusItem release];
117+
}
118+
if (helper) {
119+
[helper release];
120+
}
121+
if (normalIcon) {
122+
[normalIcon release];
123+
}
124+
if (attentionIcon) {
125+
[attentionIcon release];
126+
}
127+
}
128+
129+
NSStatusItem* statusItem = nil;
130+
IptuxStatusItemHelper* helper = nil;
131+
NSImage* normalIcon = nil;
132+
NSImage* attentionIcon = nil;
133+
};
134+
135+
IptuxAppIndicator::IptuxAppIndicator(GActionGroup* action_group) {
136+
this->priv = std::make_shared<IptuxAppIndicatorPrivate>();
137+
138+
priv->helper = [[IptuxStatusItemHelper alloc] initWithActionGroup:action_group
139+
owner:this];
140+
141+
// Load icons
142+
priv->normalIcon = loadIcon("iptux-icon", 64);
143+
priv->attentionIcon = loadIcon("iptux-attention", 64);
144+
145+
// Create status item
146+
priv->statusItem =
147+
[[[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength] retain];
148+
149+
if (priv->normalIcon) {
150+
priv->statusItem.button.image = priv->normalIcon;
151+
}
152+
priv->statusItem.button.toolTip = @"Iptux";
153+
154+
// Build the menu
155+
NSMenu* menu = [[NSMenu alloc] init];
156+
157+
NSMenuItem* openItem =
158+
[[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Open Iptux")]
159+
action:@selector(openMainWindow:)
160+
keyEquivalent:@""];
161+
openItem.target = priv->helper;
162+
[menu addItem:openItem];
163+
[openItem release];
164+
165+
[menu addItem:[NSMenuItem separatorItem]];
166+
167+
NSMenuItem* prefsItem =
168+
[[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Preferences")]
169+
action:@selector(openPreferences:)
170+
keyEquivalent:@""];
171+
prefsItem.target = priv->helper;
172+
[menu addItem:prefsItem];
173+
[prefsItem release];
174+
175+
NSMenuItem* quitItem =
176+
[[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:_("Quit")]
177+
action:@selector(quit:)
178+
keyEquivalent:@""];
179+
quitItem.target = priv->helper;
180+
[menu addItem:quitItem];
181+
[quitItem release];
182+
183+
priv->statusItem.menu = menu;
184+
[menu release];
185+
}
186+
187+
void IptuxAppIndicator::SetUnreadCount(int count) {
188+
if (!priv->statusItem) return;
189+
190+
if (count > 0 && priv->attentionIcon) {
191+
priv->statusItem.button.image = priv->attentionIcon;
192+
} else if (priv->normalIcon) {
193+
priv->statusItem.button.image = priv->normalIcon;
194+
}
195+
}
196+
197+
} // namespace iptux

src/iptux/meson.build

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ sources = files([
2626
])
2727
dependencies = [gtk_dep, jsoncpp_dep, sigc_dep]
2828

29-
if appindicator_dep.found()
29+
if host_machine.system() == 'darwin'
30+
sources += ['AppIndicatorMac.mm']
31+
dependencies += [dependency('appleframeworks', modules: ['Cocoa'])]
32+
elif appindicator_dep.found()
3033
sources += ['AppIndicator.cpp']
3134
dependencies += [appindicator_dep]
3235
else

0 commit comments

Comments
 (0)