diff --git a/images/pf.ico b/images/pf.ico new file mode 100644 index 0000000..bb96b6e Binary files /dev/null and b/images/pf.ico differ diff --git a/images/pf.png b/images/pf.png new file mode 100644 index 0000000..bb96b6e Binary files /dev/null and b/images/pf.png differ diff --git a/sync2git.bat b/sync2git.bat index 2e29995..cbe9f58 100644 --- a/sync2git.bat +++ b/sync2git.bat @@ -1,4 +1,7 @@ set GITDIR=D:\dev\db\purrforce\ +mkdir %GITDIR% +mkdir %GITDIR%images\ +mkdir %GITDIR%tray\ copy /B /Y api.h %GITDIR% copy /B /Y ls.bat %GITDIR% copy /B /Y *.c %GITDIR% diff --git a/tray/LICENSE b/tray/LICENSE new file mode 100644 index 0000000..b18604b --- /dev/null +++ b/tray/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Serge Zaitsev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tray/Makefile b/tray/Makefile new file mode 100644 index 0000000..483c0aa --- /dev/null +++ b/tray/Makefile @@ -0,0 +1,23 @@ +ifeq ($(OS),Windows_NT) + TRAY_CFLAGS := -DTRAY_WINAPI=1 + TRAY_LDFLAGS := +else ifeq ($(shell uname -s),Linux) + TRAY_CFLAGS := -DTRAY_APPINDICATOR=1 $(shell pkg-config --cflags appindicator3-0.1) + TRAY_LDFLAGS := $(shell pkg-config --libs appindicator3-0.1) +else ifeq ($(shell uname -s),Darwin) + TRAY_CFLAGS := -DTRAY_APPKIT=1 + TRAY_LDFLAGS := -framework Cocoa +endif + + +CFLAGS := -g -Wall $(TRAY_CFLAGS) -Wall -Wextra -std=c99 -pedantic +LDFLAGS := -g $(TRAY_LDFLAGS) + +all: example +example: example.o + $(CC) $^ $(LDFLAGS) -o $@ + +example.o: example.c tray.h + +clean: + rm -f example.o example example.exe diff --git a/tray/README.md b/tray/README.md new file mode 100644 index 0000000..1ec6d23 --- /dev/null +++ b/tray/README.md @@ -0,0 +1,146 @@ +Tray +---- + +Cross-platform, single header, super tiny C99 implementation of a system tray icon with a popup menu. + +Works well on: + +* Linux/Gtk (libappindicator) +* Windows XP or newer (shellapi.h) +* MacOS (Cocoa/AppKit) + +There is also a stub implementation that returns errors on attempt to create a tray menu. + +# Setup + +Before you can compile `tray`, you'll need to add an environment definition before the line where you include `tray.h`. + +**For Windows:** +```c +#include +#include + +#define TRAY_WINAPI 1 + +#include "tray.h" +... +``` + +**For Linux:** +```c +#include +#include + +#define TRAY_APPINDICATOR 1 + +#include "tray.h" +... +``` + +**For Mac:** +```c +#include +#include + +#define TRAY_APPKIT 1 + +#include "tray.h" +... +``` + +# Demo + +The included example `.c` files can be compiled based on your environment. + +For example, to compile and run the program on Windows: + +```shell +$> gcc example_windows.c [Enter] +``` + +This will compile and build `a.out`. To run it: + +``` +$> a [Enter] +``` + +# Example + +```c +struct tray tray = { + .icon = "icon.png", + .menu = (struct tray_menu[]){{"Toggle me", 0, 0, toggle_cb, NULL}, + {"-", 0, 0, NULL, NULL}, + {"Quit", 0, 0, quit_cb, NULL}, + {NULL, 0, 0, NULL, NULL}}, +}; + +void toggle_cb(struct tray_menu *item) { + item->checked = !item->checked; + tray_update(&tray); +} + +void quit_cb(struct tray_menu *item) { + tray_exit(); +} + +... + +tray_init(&tray); +while (tray_loop(1) == 0); +tray_exit(); + +``` + +# API + +Tray structure defines an icon and a menu. +Menu is a NULL-terminated array of items. +Menu item defines menu text, menu checked and disabled (grayed) flags and a +callback with some optional context pointer. + +```c +struct tray { + char *icon; + struct tray_menu *menu; +}; + +struct tray_menu { + char *text; + int disabled; + int checked; + + void (*cb)(struct tray_menu *); + void *context; + + struct tray_menu *submenu; +}; +``` + +* `int tray_init(struct tray *)` - creates tray icon. Returns -1 if tray icon/menu can't be created. +* `void tray_update(struct tray *)` - updates tray icon and menu. +* `int tray_loop(int blocking)` - runs one iteration of the UI loop. Returns -1 if `tray_exit()` has been called. +* `void tray_exit()` - terminates UI loop. + +All functions are meant to be called from the UI thread only. + +Menu arrays must be terminated with a NULL item, e.g. the last item in the +array must have text field set to NULL. + +## Roadmap + +* [x] Cross-platform tray icon +* [x] Cross-platform tray popup menu +* [x] Separators in the menu +* [x] Disabled/enabled menu items +* [x] Checked/unchecked menu items +* [x] Nested menus +* [ ] Icons for menu items +* [x] Rewrite ObjC code in C using ObjC Runtime (now ObjC code breaks many linters and static analyzers) +* [ ] Call GTK code using dlopen/dlsym (to make binaries run safely if Gtk libraries are not available) + +## License + +This software is distributed under [MIT license](http://www.opensource.org/licenses/mit-license.php), + so feel free to integrate it in your commercial products. + diff --git a/tray/example.c b/tray/example.c new file mode 100644 index 0000000..c42ffe2 --- /dev/null +++ b/tray/example.c @@ -0,0 +1,101 @@ +#include +#include + +#if defined (_WIN32) || defined (_WIN64) +#define TRAY_WINAPI 1 +#elif defined (__linux__) || defined (linux) || defined (__linux) +#define TRAY_APPINDICATOR 1 +#elif defined (__APPLE__) || defined (__MACH__) +#define TRAY_APPKIT 1 +#endif + +#include "tray.h" + +#if TRAY_APPINDICATOR +#define TRAY_ICON1 "indicator-messages" +#define TRAY_ICON2 "indicator-messages-new" +#elif TRAY_APPKIT +#define TRAY_ICON1 "icon.png" +#define TRAY_ICON2 "icon.png" +#elif TRAY_WINAPI +#define TRAY_ICON1 "icon.ico" +#define TRAY_ICON2 "icon.ico" +#endif + +static struct tray tray; + +static void toggle_cb(struct tray_menu *item) { + printf("toggle cb\n"); + item->checked = !item->checked; + tray_update(&tray); +} + +static void hello_cb(struct tray_menu *item) { + (void)item; + printf("hello cb\n"); + if (strcmp(tray.icon, TRAY_ICON1) == 0) { + tray.icon = TRAY_ICON2; + } else { + tray.icon = TRAY_ICON1; + } + tray_update(&tray); +} + +static void quit_cb(struct tray_menu *item) { + (void)item; + printf("quit cb\n"); + tray_exit(); +} + +static void submenu_cb(struct tray_menu *item) { + (void)item; + printf("submenu: clicked on %s\n", item->text); + tray_update(&tray); +} + +// Test tray init +static struct tray tray = { + .icon = TRAY_ICON1, + .menu = + (struct tray_menu[]){ + {.text = "Hello", .cb = hello_cb}, + {.text = "Checked", .checked = 1, .cb = toggle_cb}, + {.text = "Disabled", .disabled = 1}, + {.text = "-"}, + {.text = "SubMenu", + .submenu = + (struct tray_menu[]){ + {.text = "FIRST", .checked = 1, .cb = submenu_cb}, + {.text = "SECOND", + .submenu = + (struct tray_menu[]){ + {.text = "THIRD", + .submenu = + (struct tray_menu[]){ + {.text = "7", .cb = submenu_cb}, + {.text = "-"}, + {.text = "8", .cb = submenu_cb}, + {.text = NULL}}}, + {.text = "FOUR", + .submenu = + (struct tray_menu[]){ + {.text = "5", .cb = submenu_cb}, + {.text = "6", .cb = submenu_cb}, + {.text = NULL}}}, + {.text = NULL}}}, + {.text = NULL}}}, + {.text = "-"}, + {.text = "Quit", .cb = quit_cb}, + {.text = NULL}}, +}; + +int main() { + if (tray_init(&tray) < 0) { + printf("failed to create tray\n"); + return 1; + } + while (tray_loop(1) == 0) { + printf("iteration\n"); + } + return 0; +} diff --git a/tray/icon.ico b/tray/icon.ico new file mode 100644 index 0000000..bb96b6e Binary files /dev/null and b/tray/icon.ico differ diff --git a/tray/icon.png b/tray/icon.png new file mode 100644 index 0000000..bb96b6e Binary files /dev/null and b/tray/icon.png differ diff --git a/tray/tray.h b/tray/tray.h new file mode 100644 index 0000000..4ba47ea --- /dev/null +++ b/tray/tray.h @@ -0,0 +1,370 @@ +#ifndef TRAY_H +#define TRAY_H + +struct tray_menu; + +struct tray { + char *icon; + struct tray_menu *menu; +}; + +struct tray_menu { + char *text; + int disabled; + int checked; + + void (*cb)(struct tray_menu *); + void *context; + + struct tray_menu *submenu; +}; + +static void tray_update(struct tray *tray); + +#if defined(TRAY_APPINDICATOR) + +#include +#include + +#define TRAY_APPINDICATOR_ID "tray-id" + +static AppIndicator *indicator = NULL; +static int loop_result = 0; + +static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { + (void)item; + struct tray_menu *m = (struct tray_menu *)data; + m->cb(m); +} + +static GtkMenuShell *_tray_menu(struct tray_menu *m) { + GtkMenuShell *menu = (GtkMenuShell *)gtk_menu_new(); + for (; m != NULL && m->text != NULL; m++) { + GtkWidget *item; + if (strcmp(m->text, "-") == 0) { + item = gtk_separator_menu_item_new(); + } else { + if (m->submenu != NULL) { + item = gtk_menu_item_new_with_label(m->text); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), + GTK_WIDGET(_tray_menu(m->submenu))); + } else { + item = gtk_check_menu_item_new_with_label(m->text); + gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), !!m->checked); + } + gtk_widget_set_sensitive(item, !m->disabled); + if (m->cb != NULL) { + g_signal_connect(item, "activate", G_CALLBACK(_tray_menu_cb), m); + } + } + gtk_widget_show(item); + gtk_menu_shell_append(menu, item); + } + return menu; +} + +static int tray_init(struct tray *tray) { + if (gtk_init_check(0, NULL) == FALSE) { + return -1; + } + indicator = app_indicator_new(TRAY_APPINDICATOR_ID, tray->icon, + APP_INDICATOR_CATEGORY_APPLICATION_STATUS); + app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE); + tray_update(tray); + return 0; +} + +static int tray_loop(int blocking) { + gtk_main_iteration_do(blocking); + return loop_result; +} + +static void tray_update(struct tray *tray) { + app_indicator_set_icon(indicator, tray->icon); + // GTK is all about reference counting, so previous menu should be destroyed + // here + app_indicator_set_menu(indicator, GTK_MENU(_tray_menu(tray->menu))); +} + +static void tray_exit() { loop_result = -1; } + +#elif defined(TRAY_APPKIT) + +#include +#include + +static id app; +static id pool; +static id statusBar; +static id statusItem; +static id statusBarButton; + +static id _tray_menu(struct tray_menu *m) { + id menu = objc_msgSend((id)objc_getClass("NSMenu"), sel_registerName("new")); + objc_msgSend(menu, sel_registerName("autorelease")); + objc_msgSend(menu, sel_registerName("setAutoenablesItems:"), false); + + for (; m != NULL && m->text != NULL; m++) { + if (strcmp(m->text, "-") == 0) { + objc_msgSend(menu, sel_registerName("addItem:"), + objc_msgSend((id)objc_getClass("NSMenuItem"), sel_registerName("separatorItem"))); + } else { + id menuItem = objc_msgSend((id)objc_getClass("NSMenuItem"), sel_registerName("alloc")); + objc_msgSend(menuItem, sel_registerName("autorelease")); + objc_msgSend(menuItem, sel_registerName("initWithTitle:action:keyEquivalent:"), + objc_msgSend((id)objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), m->text), + sel_registerName("menuCallback:"), + objc_msgSend((id)objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), "")); + + objc_msgSend(menuItem, sel_registerName("setEnabled:"), (m->disabled ? false : true)); + objc_msgSend(menuItem, sel_registerName("setState:"), (m->checked ? 1 : 0)); + objc_msgSend(menuItem, sel_registerName("setRepresentedObject:"), + objc_msgSend((id)objc_getClass("NSValue"), sel_registerName("valueWithPointer:"), m)); + + objc_msgSend(menu, sel_registerName("addItem:"), menuItem); + + if (m->submenu != NULL) { + objc_msgSend(menu, sel_registerName("setSubmenu:forItem:"), _tray_menu(m->submenu), menuItem); + } + } + } + + return menu; +} + +static void menu_callback(id self, SEL cmd, id sender) { + struct tray_menu *m = + (struct tray_menu *)objc_msgSend(objc_msgSend(sender, sel_registerName("representedObject")), + sel_registerName("pointerValue")); + + if (m != NULL && m->cb != NULL) { + m->cb(m); + } +} + +static int tray_init(struct tray *tray) { + pool = objc_msgSend((id)objc_getClass("NSAutoreleasePool"), + sel_registerName("new")); + + objc_msgSend((id)objc_getClass("NSApplication"), + sel_registerName("sharedApplication")); + + Class trayDelegateClass = objc_allocateClassPair(objc_getClass("NSObject"), "Tray", 0); + class_addProtocol(trayDelegateClass, objc_getProtocol("NSApplicationDelegate")); + class_addMethod(trayDelegateClass, sel_registerName("menuCallback:"), (IMP)menu_callback, "v@:@"); + objc_registerClassPair(trayDelegateClass); + + id trayDelegate = objc_msgSend((id)trayDelegateClass, + sel_registerName("new")); + + app = objc_msgSend((id)objc_getClass("NSApplication"), + sel_registerName("sharedApplication")); + + objc_msgSend(app, sel_registerName("setDelegate:"), trayDelegate); + + statusBar = objc_msgSend((id)objc_getClass("NSStatusBar"), + sel_registerName("systemStatusBar")); + + statusItem = objc_msgSend(statusBar, sel_registerName("statusItemWithLength:"), -1.0); + + objc_msgSend(statusItem, sel_registerName("retain")); + objc_msgSend(statusItem, sel_registerName("setHighlightMode:"), true); + statusBarButton = objc_msgSend(statusItem, sel_registerName("button")); + tray_update(tray); + objc_msgSend(app, sel_registerName("activateIgnoringOtherApps:"), true); + return 0; +} + +static int tray_loop(int blocking) { + id until = (blocking ? + objc_msgSend((id)objc_getClass("NSDate"), sel_registerName("distantFuture")) : + objc_msgSend((id)objc_getClass("NSDate"), sel_registerName("distantPast"))); + + id event = objc_msgSend(app, sel_registerName("nextEventMatchingMask:untilDate:inMode:dequeue:"), + ULONG_MAX, + until, + objc_msgSend((id)objc_getClass("NSString"), + sel_registerName("stringWithUTF8String:"), + "kCFRunLoopDefaultMode"), + true); + if (event) { + objc_msgSend(app, sel_registerName("sendEvent:"), event); + } + return 0; +} + +static void tray_update(struct tray *tray) { + objc_msgSend(statusBarButton, sel_registerName("setImage:"), + objc_msgSend((id)objc_getClass("NSImage"), sel_registerName("imageNamed:"), + objc_msgSend((id)objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), tray->icon))); + + objc_msgSend(statusItem, sel_registerName("setMenu:"), _tray_menu(tray->menu)); +} + +static void tray_exit() { objc_msgSend(app, sel_registerName("terminate:"), app); } + +#elif defined(TRAY_WINAPI) +#include + +#include + +#define WM_TRAY_CALLBACK_MESSAGE (WM_USER + 1) +#define WC_TRAY_CLASS_NAME "TRAY" +#define ID_TRAY_FIRST 1000 + +static WNDCLASSEX wc; +static NOTIFYICONDATA nid; +static HWND hwnd; +static HMENU hmenu = NULL; + +static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, + LPARAM lparam) { + switch (msg) { + case WM_CLOSE: + DestroyWindow(hwnd); + return 0; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + case WM_TRAY_CALLBACK_MESSAGE: + if (lparam == WM_LBUTTONUP || lparam == WM_RBUTTONUP) { + POINT p; + GetCursorPos(&p); + SetForegroundWindow(hwnd); + WORD cmd = TrackPopupMenu(hmenu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | + TPM_RETURNCMD | TPM_NONOTIFY, + p.x, p.y, 0, hwnd, NULL); + SendMessage(hwnd, WM_COMMAND, cmd, 0); + return 0; + } + break; + case WM_COMMAND: + if (wparam >= ID_TRAY_FIRST) { + MENUITEMINFO item = { + .cbSize = sizeof(MENUITEMINFO), .fMask = MIIM_ID | MIIM_DATA, + }; + if (GetMenuItemInfo(hmenu, wparam, FALSE, &item)) { + struct tray_menu *menu = (struct tray_menu *)item.dwItemData; + if (menu != NULL && menu->cb != NULL) { + menu->cb(menu); + } + } + return 0; + } + break; + } + return DefWindowProc(hwnd, msg, wparam, lparam); +} + +static HMENU _tray_menu(struct tray_menu *m, UINT *id) { + HMENU hmenu = CreatePopupMenu(); + for (; m != NULL && m->text != NULL; m++, (*id)++) { + if (strcmp(m->text, "-") == 0) { + InsertMenu(hmenu, *id, MF_SEPARATOR, TRUE, ""); + } else { + MENUITEMINFO item; + memset(&item, 0, sizeof(item)); + item.cbSize = sizeof(MENUITEMINFO); + item.fMask = MIIM_ID | MIIM_TYPE | MIIM_STATE | MIIM_DATA; + item.fType = 0; + item.fState = 0; + if (m->submenu != NULL) { + item.fMask = item.fMask | MIIM_SUBMENU; + item.hSubMenu = _tray_menu(m->submenu, id); + } + if (m->disabled) { + item.fState |= MFS_DISABLED; + } + if (m->checked) { + item.fState |= MFS_CHECKED; + } + item.wID = *id; + item.dwTypeData = m->text; + item.dwItemData = (ULONG_PTR)m; + + InsertMenuItem(hmenu, *id, TRUE, &item); + } + } + return hmenu; +} + +static int tray_init(struct tray *tray) { + memset(&wc, 0, sizeof(wc)); + wc.cbSize = sizeof(WNDCLASSEX); + wc.lpfnWndProc = _tray_wnd_proc; + wc.hInstance = GetModuleHandle(NULL); + wc.lpszClassName = WC_TRAY_CLASS_NAME; + if (!RegisterClassEx(&wc)) { + return -1; + } + + hwnd = CreateWindowEx(0, WC_TRAY_CLASS_NAME, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0); + if (hwnd == NULL) { + return -1; + } + UpdateWindow(hwnd); + + memset(&nid, 0, sizeof(nid)); + nid.cbSize = sizeof(NOTIFYICONDATA); + nid.hWnd = hwnd; + nid.uID = 0; + nid.uFlags = NIF_ICON | NIF_MESSAGE; + nid.uCallbackMessage = WM_TRAY_CALLBACK_MESSAGE; + Shell_NotifyIcon(NIM_ADD, &nid); + + tray_update(tray); + return 0; +} + +static int tray_loop(int blocking) { + MSG msg; + if (blocking) { + GetMessage(&msg, NULL, 0, 0); + } else { + PeekMessage(&msg, NULL, 0, 0, PM_REMOVE); + } + if (msg.message == WM_QUIT) { + return -1; + } + TranslateMessage(&msg); + DispatchMessage(&msg); + return 0; +} + +static void tray_update(struct tray *tray) { + HMENU prevmenu = hmenu; + UINT id = ID_TRAY_FIRST; + hmenu = _tray_menu(tray->menu, &id); + SendMessage(hwnd, WM_INITMENUPOPUP, (WPARAM)hmenu, 0); + HICON icon; + ExtractIconEx(tray->icon, 0, NULL, &icon, 1); + if (nid.hIcon) { + DestroyIcon(nid.hIcon); + } + nid.hIcon = icon; + Shell_NotifyIcon(NIM_MODIFY, &nid); + + if (prevmenu != NULL) { + DestroyMenu(prevmenu); + } +} + +static void tray_exit() { + Shell_NotifyIcon(NIM_DELETE, &nid); + if (nid.hIcon != 0) { + DestroyIcon(nid.hIcon); + } + if (hmenu != 0) { + DestroyMenu(hmenu); + } + PostQuitMessage(0); + UnregisterClass(WC_TRAY_CLASS_NAME, GetModuleHandle(NULL)); +} +#else +static int tray_init(struct tray *tray) { return -1; } +static int tray_loop(int blocking) { return -1; } +static void tray_update(struct tray *tray) {} +static void tray_exit(); +#endif + +#endif /* TRAY_H */