Skip to content

Commit 154790a

Browse files
committed
feat(linux): implement keyboard shortcut hints via DBusMenu
Add native support for displaying keyboard shortcuts in Linux tray menus via the DBusMenu `shortcut` property (aas type). Shortcuts serialize as DBusMenu-compliant modifier+key arrays and render natively on KDE Plasma. GNOME's AppIndicator extension does not render this property visually. - Extend menu_item struct with shortcut fields (key + modifier flags) - Serialize shortcut property in GetLayout and GetGroupProperties responses - Add sni_tray_item_set_shortcut() C API and JNI bridge - Wire shortcuts from Kotlin builder through manager to native code - Add linuxKeyName to Key enum (XKB naming convention) Verified on D-Bus: shortcuts correctly exposed as aas arrays.
1 parent 14e703e commit 154790a

7 files changed

Lines changed: 171 additions & 36 deletions

File tree

src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@ internal object LinuxNativeBridge {
162162
iconBytes: ByteArray,
163163
)
164164

165+
@JvmStatic external fun nativeItemSetShortcut(
166+
handle: Long,
167+
id: Int,
168+
key: String,
169+
ctrl: Boolean,
170+
shift: Boolean,
171+
alt: Boolean,
172+
superMod: Boolean,
173+
)
174+
165175
// -- X11 outside-click watcher -----------------------------------------------
166176

167177
/** Open X11 display. Returns handle, or 0 if X11 is unavailable. */

src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ internal class LinuxTrayManager(
3636
val isCheckable: Boolean = false,
3737
val isChecked: Boolean = false,
3838
val iconPath: String? = null,
39+
val shortcut: com.kdroid.composetray.menu.api.KeyShortcut? = null,
3940
val onClick: (() -> Unit)? = null,
4041
val subMenuItems: List<MenuItem> = emptyList(),
4142
)
@@ -358,6 +359,17 @@ internal class LinuxTrayManager(
358359
}.onFailure { e -> warnln { "[LinuxTrayManager] Failed to set menu item icon: ${e.message}" } }
359360
}
360361

362+
// Keyboard shortcut hint
363+
item.shortcut?.let { shortcut ->
364+
runCatching {
365+
native.nativeItemSetShortcut(
366+
trayHandle, id,
367+
shortcut.toLinuxKey(),
368+
shortcut.ctrl, shortcut.shift, shortcut.alt, shortcut.meta,
369+
)
370+
}.onFailure { e -> warnln { "[LinuxTrayManager] Failed to set shortcut: ${e.message}" } }
371+
}
372+
361373
// Submenu
362374
if (item.subMenuItems.isNotEmpty()) {
363375
item.subMenuItems.forEach { sub -> addMenuItemRecursive(id, sub) }

src/jvmMain/kotlin/com/kdroid/composetray/menu/api/KeyShortcut.kt

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package com.kdroid.composetray.menu.api
55
* This is display-only — it does not register any global hotkey handler.
66
*
77
* On macOS, the shortcut renders as native key equivalent glyphs (e.g. ⌘S, ⇧⌘N).
8-
* On other platforms, this is currently a no-op.
8+
* On Linux, the shortcut is serialized as a DBusMenu `shortcut` property
9+
* and rendered by the desktop environment's indicator renderer (KDE, GNOME, etc.).
10+
* On Windows, this is currently a no-op.
911
*
1012
* Example:
1113
* ```
@@ -37,51 +39,62 @@ data class KeyShortcut(
3739
* Lowercase letter for regular key, special Unicode for function/special keys.
3840
*/
3941
internal fun toMacKeyEquivalent(): String = key.macKeyEquivalent
42+
43+
/**
44+
* Returns the DBusMenu key name for Linux shortcut hints.
45+
*/
46+
internal fun toLinuxKey(): String = key.linuxKeyName
4047
}
4148

4249
/**
4350
* Keyboard keys that can be used in [KeyShortcut].
4451
*
4552
* @property macKeyEquivalent The string value passed to NSMenuItem.setKeyEquivalent on macOS.
53+
* @property linuxKeyName The DBusMenu key name string for Linux (follows XKB naming).
4654
*/
47-
enum class Key(internal val macKeyEquivalent: String) {
48-
A("a"), B("b"), C("c"), D("d"), E("e"), F("f"), G("g"), H("h"),
49-
I("i"), J("j"), K("k"), L("l"), M("m"), N("n"), O("o"), P("p"),
50-
Q("q"), R("r"), S("s"), T("t"), U("u"), V("v"), W("w"), X("x"),
51-
Y("y"), Z("z"),
55+
enum class Key(
56+
internal val macKeyEquivalent: String,
57+
internal val linuxKeyName: String,
58+
) {
59+
A("a", "a"), B("b", "b"), C("c", "c"), D("d", "d"), E("e", "e"),
60+
F("f", "f"), G("g", "g"), H("h", "h"), I("i", "i"), J("j", "j"),
61+
K("k", "k"), L("l", "l"), M("m", "m"), N("n", "n"), O("o", "o"),
62+
P("p", "p"), Q("q", "q"), R("r", "r"), S("s", "s"), T("t", "t"),
63+
U("u", "u"), V("v", "v"), W("w", "w"), X("x", "x"), Y("y", "y"),
64+
Z("z", "z"),
5265

53-
Num0("0"), Num1("1"), Num2("2"), Num3("3"), Num4("4"),
54-
Num5("5"), Num6("6"), Num7("7"), Num8("8"), Num9("9"),
66+
Num0("0", "0"), Num1("1", "1"), Num2("2", "2"), Num3("3", "3"), Num4("4", "4"),
67+
Num5("5", "5"), Num6("6", "6"), Num7("7", "7"), Num8("8", "8"), Num9("9", "9"),
5568

5669
// Function keys (AppKit private-use Unicode)
57-
F1("\uF704"), F2("\uF705"), F3("\uF706"), F4("\uF707"),
58-
F5("\uF708"), F6("\uF709"), F7("\uF70A"), F8("\uF70B"),
59-
F9("\uF70C"), F10("\uF70D"), F11("\uF70E"), F12("\uF70F"),
70+
F1("\uF704", "F1"), F2("\uF705", "F2"), F3("\uF706", "F3"), F4("\uF707", "F4"),
71+
F5("\uF708", "F5"), F6("\uF709", "F6"), F7("\uF70A", "F7"), F8("\uF70B", "F8"),
72+
F9("\uF70C", "F9"), F10("\uF70D", "F10"), F11("\uF70E", "F11"), F12("\uF70F", "F12"),
6073

6174
// Special keys
62-
Return("\r"),
63-
Tab("\t"),
64-
Space(" "),
65-
Escape("\u001B"),
66-
Delete("\u007F"),
67-
ForwardDelete("\uF728"),
68-
UpArrow("\uF700"),
69-
DownArrow("\uF701"),
70-
LeftArrow("\uF702"),
71-
RightArrow("\uF703"),
72-
Home("\uF729"),
73-
End("\uF72B"),
74-
PageUp("\uF72C"),
75-
PageDown("\uF72D"),
76-
Minus("-"),
77-
Equal("="),
78-
LeftBracket("["),
79-
RightBracket("]"),
80-
Backslash("\\"),
81-
Semicolon(";"),
82-
Quote("'"),
83-
Comma(","),
84-
Period("."),
85-
Slash("/"),
86-
Backquote("`"),
75+
Return("\r", "Return"),
76+
Tab("\t", "Tab"),
77+
Space(" ", "space"),
78+
Escape("\u001B", "Escape"),
79+
Delete("\u007F", "BackSpace"),
80+
ForwardDelete("\uF728", "Delete"),
81+
UpArrow("\uF700", "Up"),
82+
DownArrow("\uF701", "Down"),
83+
LeftArrow("\uF702", "Left"),
84+
RightArrow("\uF703", "Right"),
85+
Home("\uF729", "Home"),
86+
End("\uF72B", "End"),
87+
PageUp("\uF72C", "Page_Up"),
88+
PageDown("\uF72D", "Page_Down"),
89+
Minus("-", "minus"),
90+
Equal("=", "equal"),
91+
LeftBracket("[", "bracketleft"),
92+
RightBracket("]", "bracketright"),
93+
Backslash("\\", "backslash"),
94+
Semicolon(";", "semicolon"),
95+
Quote("'", "apostrophe"),
96+
Comma(",", "comma"),
97+
Period(".", "period"),
98+
Slash("/", "slash"),
99+
Backquote("`", "grave"),
87100
}

src/jvmMain/kotlin/com/kdroid/composetray/menu/impl/LinuxTrayMenuBuilderImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ internal class LinuxTrayMenuBuilderImpl(
4242
LinuxTrayManager.MenuItem(
4343
text = label,
4444
isEnabled = isEnabled,
45+
shortcut = shortcut,
4546
onClick = onClick,
4647
)
4748
menuItems.add(menuItem)
@@ -65,6 +66,7 @@ internal class LinuxTrayMenuBuilderImpl(
6566
text = label,
6667
isEnabled = isEnabled,
6768
iconPath = iconPath,
69+
shortcut = shortcut,
6870
onClick = onClick,
6971
)
7072
menuItems.add(menuItem)
@@ -154,6 +156,7 @@ internal class LinuxTrayMenuBuilderImpl(
154156
isEnabled = isEnabled,
155157
isCheckable = true,
156158
isChecked = initialChecked,
159+
shortcut = shortcut,
157160
onClick = {
158161
lock.withLock {
159162
val currentMenuItem = menuItems.find { it.text == label }
@@ -191,6 +194,7 @@ internal class LinuxTrayMenuBuilderImpl(
191194
isCheckable = true,
192195
isChecked = initialChecked,
193196
iconPath = iconPath,
197+
shortcut = shortcut,
194198
onClick = {
195199
lock.withLock {
196200
val currentMenuItem = menuItems.find { it.text == label }

src/native/linux/jni_bridge.c

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,21 @@ Java_com_kdroid_composetray_lib_linux_LinuxNativeBridge_nativeItemSetIcon(
513513
(*env)->ReleaseByteArrayElements(env, iconBytes, buf, JNI_ABORT);
514514
}
515515

516+
JNIEXPORT void JNICALL
517+
Java_com_kdroid_composetray_lib_linux_LinuxNativeBridge_nativeItemSetShortcut(
518+
JNIEnv *env, jclass clazz, jlong handle, jint id,
519+
jstring key, jboolean ctrl, jboolean shift, jboolean alt, jboolean superMod)
520+
{
521+
(void)clazz;
522+
sni_tray *tray = (sni_tray *)(uintptr_t)handle;
523+
if (!tray) return;
524+
const char *k = key ? (*env)->GetStringUTFChars(env, key, NULL) : NULL;
525+
sni_tray_item_set_shortcut(tray, (uint32_t)id, k,
526+
ctrl ? 1 : 0, shift ? 1 : 0,
527+
alt ? 1 : 0, superMod ? 1 : 0);
528+
if (k) (*env)->ReleaseStringUTFChars(env, key, k);
529+
}
530+
516531
/* ========================================================================== */
517532
/* X11 outside-click watcher (dynamically loaded to avoid hard dependency) */
518533
/* ========================================================================== */

src/native/linux/sni.c

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ typedef struct menu_item {
6767
uint8_t *icon_data;
6868
size_t icon_len;
6969

70+
/* Keyboard shortcut hint (display-only, DBusMenu "shortcut" property) */
71+
char *shortcut_key; /* e.g. "s", "F1", "Delete" */
72+
int shortcut_ctrl;
73+
int shortcut_shift;
74+
int shortcut_alt;
75+
int shortcut_super; /* Meta key */
76+
7077
/* Tree structure */
7178
int32_t parent_id; /* 0 = root */
7279
struct menu_item *children;
@@ -277,6 +284,7 @@ static void free_menu_items(sni_tray *tray) {
277284
free(tray->items[i].label);
278285
free(tray->items[i].tooltip);
279286
free(tray->items[i].icon_data);
287+
free(tray->items[i].shortcut_key);
280288
free(tray->items[i].children);
281289
}
282290
free(tray->items);
@@ -626,6 +634,37 @@ static int append_menu_layout(sd_bus_message *reply, sni_tray *tray,
626634
if (r < 0) return r;
627635
}
628636

637+
/* Keyboard shortcut hint: DBusMenu "shortcut" property (type aas) */
638+
if (item->shortcut_key) {
639+
r = sd_bus_message_open_container(reply, 'e', "sv");
640+
if (r < 0) return r;
641+
r = sd_bus_message_append(reply, "s", "shortcut");
642+
if (r < 0) return r;
643+
r = sd_bus_message_open_container(reply, 'v', "aas");
644+
if (r < 0) return r;
645+
r = sd_bus_message_open_container(reply, 'a', "as");
646+
if (r < 0) return r;
647+
r = sd_bus_message_open_container(reply, 'a', "s");
648+
if (r < 0) return r;
649+
if (item->shortcut_ctrl)
650+
sd_bus_message_append(reply, "s", "Control");
651+
if (item->shortcut_shift)
652+
sd_bus_message_append(reply, "s", "Shift");
653+
if (item->shortcut_alt)
654+
sd_bus_message_append(reply, "s", "Alt");
655+
if (item->shortcut_super)
656+
sd_bus_message_append(reply, "s", "Super");
657+
sd_bus_message_append(reply, "s", item->shortcut_key);
658+
r = sd_bus_message_close_container(reply); /* as (inner) */
659+
if (r < 0) return r;
660+
r = sd_bus_message_close_container(reply); /* a (outer) */
661+
if (r < 0) return r;
662+
r = sd_bus_message_close_container(reply); /* v */
663+
if (r < 0) return r;
664+
r = sd_bus_message_close_container(reply); /* e */
665+
if (r < 0) return r;
666+
}
667+
629668
/* If this item has children, mark as submenu parent */
630669
int32_t child_ids[MAX_MENU_ITEMS];
631670
int child_count = get_children(tray, item_id, child_ids, MAX_MENU_ITEMS);
@@ -742,6 +781,26 @@ static int menu_get_group_properties(sd_bus_message *msg, void *userdata, sd_bus
742781
sd_bus_message_append(reply, "{sv}", "toggle-type", "s", "checkmark");
743782
sd_bus_message_append(reply, "{sv}", "toggle-state", "i", item->checked ? 1 : 0);
744783
}
784+
if (item->shortcut_key) {
785+
sd_bus_message_open_container(reply, 'e', "sv");
786+
sd_bus_message_append(reply, "s", "shortcut");
787+
sd_bus_message_open_container(reply, 'v', "aas");
788+
sd_bus_message_open_container(reply, 'a', "as");
789+
sd_bus_message_open_container(reply, 'a', "s");
790+
if (item->shortcut_ctrl)
791+
sd_bus_message_append(reply, "s", "Control");
792+
if (item->shortcut_shift)
793+
sd_bus_message_append(reply, "s", "Shift");
794+
if (item->shortcut_alt)
795+
sd_bus_message_append(reply, "s", "Alt");
796+
if (item->shortcut_super)
797+
sd_bus_message_append(reply, "s", "Super");
798+
sd_bus_message_append(reply, "s", item->shortcut_key);
799+
sd_bus_message_close_container(reply);
800+
sd_bus_message_close_container(reply);
801+
sd_bus_message_close_container(reply);
802+
sd_bus_message_close_container(reply);
803+
}
745804
}
746805
}
747806

@@ -1307,3 +1366,18 @@ void sni_tray_item_set_icon(sni_tray *tray, uint32_t id,
13071366
}
13081367
emit_layout_updated(tray);
13091368
}
1369+
1370+
void sni_tray_item_set_shortcut(sni_tray *tray, uint32_t id,
1371+
const char *key,
1372+
int ctrl, int shift, int alt, int super_mod) {
1373+
if (!tray) return;
1374+
menu_item *item = find_item(tray, (int32_t)id);
1375+
if (!item) return;
1376+
free(item->shortcut_key);
1377+
item->shortcut_key = key ? strdup(key) : NULL;
1378+
item->shortcut_ctrl = ctrl;
1379+
item->shortcut_shift = shift;
1380+
item->shortcut_alt = alt;
1381+
item->shortcut_super = super_mod;
1382+
emit_layout_updated(tray);
1383+
}

src/native/linux/sni.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ void sni_tray_item_uncheck(sni_tray *tray, uint32_t id);
9797
void sni_tray_item_set_icon(sni_tray *tray, uint32_t id,
9898
const uint8_t *icon_data, size_t icon_len);
9999

100+
/* Set a display-only keyboard shortcut hint on a menu item.
101+
* key: DBusMenu key name (e.g. "s", "F1", "Delete").
102+
* Modifier flags: 1 = active, 0 = inactive. */
103+
void sni_tray_item_set_shortcut(sni_tray *tray, uint32_t id,
104+
const char *key,
105+
int ctrl, int shift, int alt, int super_mod);
106+
100107
#ifdef __cplusplus
101108
}
102109
#endif

0 commit comments

Comments
 (0)