Skip to content

Commit 14e703e

Browse files
committed
feat: add keyboard shortcut hints to tray menu items (#174)
Add display-only keyboard shortcut hints on macOS via NSMenuItem key equivalents. Windows and Linux accept the parameter as noop. - New KeyShortcut data class with Key enum and modifier flags - TrayMenuBuilder.Item/CheckableItem gain shortcut parameter - macOS: extends tray_menu_item struct with key_equivalent fields, sets keyEquivalent and keyEquivalentModifierMask on NSMenuItem - Demo: add shortcut hints to DynamicIconsDemo menu items
1 parent b5df040 commit 14e703e

14 files changed

Lines changed: 273 additions & 161 deletions

File tree

demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import androidx.compose.ui.graphics.Color
1818
import androidx.compose.ui.unit.dp
1919
import androidx.compose.ui.window.Window
2020
import androidx.compose.ui.window.application
21+
import com.kdroid.composetray.menu.api.Key
22+
import com.kdroid.composetray.menu.api.KeyShortcut
2123
import com.kdroid.composetray.tray.api.Tray
2224
import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel
2325
import com.kdroid.composetray.utils.SingleInstanceManager
@@ -88,12 +90,12 @@ fun main() = application {
8890
) {
8991
// Weather submenu with dynamic icon
9092
SubMenu(label = "Weather", icon = weatherIcon) {
91-
Item(label = "Sunny", icon = Icons.Default.WbSunny) {
93+
Item(label = "Sunny", icon = Icons.Default.WbSunny, shortcut = KeyShortcut(Key.Num1, meta = true)) {
9294
println("$logTag: Weather set to Sunny")
9395
weatherIcon = Icons.Default.WbSunny
9496
}
9597

96-
Item(label = "Cloudy", icon = Icons.Default.Cloud) {
98+
Item(label = "Cloudy", icon = Icons.Default.Cloud, shortcut = KeyShortcut(Key.Num2, meta = true)) {
9799
println("$logTag: Weather set to Cloudy")
98100
weatherIcon = Icons.Default.Cloud
99101
}
@@ -113,12 +115,12 @@ fun main() = application {
113115

114116
// Music submenu with dynamic icon
115117
SubMenu(label = "Music", icon = musicIcon) {
116-
Item(label = "Play", icon = Icons.Default.PlayArrow) {
118+
Item(label = "Play", icon = Icons.Default.PlayArrow, shortcut = KeyShortcut(Key.P, meta = true)) {
117119
println("$logTag: Music playing")
118120
musicIcon = Icons.Default.PlayArrow
119121
}
120122

121-
Item(label = "Pause", icon = Icons.Default.Pause) {
123+
Item(label = "Pause", icon = Icons.Default.Pause, shortcut = KeyShortcut(Key.P, meta = true, shift = true)) {
122124
println("$logTag: Music paused")
123125
musicIcon = Icons.Default.Pause
124126
}
@@ -130,15 +132,15 @@ fun main() = application {
130132

131133
Divider()
132134

133-
Item(label = "Volume Up", icon = Icons.Default.VolumeUp) {
135+
Item(label = "Volume Up", icon = Icons.Default.VolumeUp, shortcut = KeyShortcut(Key.UpArrow, meta = true)) {
134136
println("$logTag: Volume increased")
135137
}
136138

137-
Item(label = "Volume Down", icon = Icons.Default.VolumeDown) {
139+
Item(label = "Volume Down", icon = Icons.Default.VolumeDown, shortcut = KeyShortcut(Key.DownArrow, meta = true)) {
138140
println("$logTag: Volume decreased")
139141
}
140142

141-
Item(label = "Mute", icon = Icons.Default.VolumeMute) {
143+
Item(label = "Mute", icon = Icons.Default.VolumeMute, shortcut = KeyShortcut(Key.M, meta = true, shift = true)) {
142144
println("$logTag: Volume muted")
143145
}
144146
}
@@ -211,12 +213,12 @@ fun main() = application {
211213

212214
Divider()
213215

214-
Item(label = "Hide in tray", icon = Icons.Default.VisibilityOff) {
216+
Item(label = "Hide in tray", icon = Icons.Default.VisibilityOff, shortcut = KeyShortcut(Key.H, meta = true)) {
215217
isWindowVisible = false
216218
println("$logTag: Application hidden in tray")
217219
}
218220

219-
Item(label = "Exit", icon = Icons.Default.ExitToApp) {
221+
Item(label = "Exit", icon = Icons.Default.ExitToApp, shortcut = KeyShortcut(Key.Q, meta = true)) {
220222
println("$logTag: Exiting application")
221223
dispose()
222224
exitApplication()

src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacNativeBridge.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ internal object MacNativeBridge {
7676
callback: Runnable?,
7777
)
7878

79+
@JvmStatic external fun nativeSetMenuItemShortcut(
80+
menuHandle: Long,
81+
index: Int,
82+
keyEquivalent: String?,
83+
modifierMask: Long,
84+
)
85+
7986
@JvmStatic external fun nativeSetMenuItemSubmenu(
8087
menuHandle: Long,
8188
index: Int,

src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
66
import kotlinx.coroutines.SupervisorJob
77
import kotlinx.coroutines.cancel
88
import kotlinx.coroutines.launch
9+
import com.kdroid.composetray.menu.api.KeyShortcut
910
import java.util.concurrent.CountDownLatch
1011
import java.util.concurrent.atomic.AtomicBoolean
1112
import java.util.concurrent.locks.ReentrantLock
@@ -43,6 +44,7 @@ internal class MacTrayManager(
4344
val isEnabled: Boolean = true,
4445
val isCheckable: Boolean = false,
4546
val isChecked: Boolean = false,
47+
val shortcut: KeyShortcut? = null,
4648
val onClick: (() -> Unit)? = null,
4749
val subMenuItems: List<MenuItem> = emptyList(),
4850
)
@@ -283,6 +285,15 @@ internal class MacTrayManager(
283285
if (menuItem.isChecked) 1 else 0,
284286
)
285287

288+
menuItem.shortcut?.let { shortcut ->
289+
MacNativeBridge.nativeSetMenuItemShortcut(
290+
parentHandle,
291+
index,
292+
shortcut.toMacKeyEquivalent(),
293+
shortcut.toMacModifierMask(),
294+
)
295+
}
296+
286297
menuItem.onClick?.let { onClick ->
287298
MacNativeBridge.nativeSetMenuItemCallback(
288299
parentHandle,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.kdroid.composetray.menu.api
2+
3+
/**
4+
* Represents a keyboard shortcut hint displayed alongside a menu item.
5+
* This is display-only — it does not register any global hotkey handler.
6+
*
7+
* On macOS, the shortcut renders as native key equivalent glyphs (e.g. ⌘S, ⇧⌘N).
8+
* On other platforms, this is currently a no-op.
9+
*
10+
* Example:
11+
* ```
12+
* Item("Save", shortcut = KeyShortcut(Key.S, meta = true)) { ... }
13+
* Item("New Window", shortcut = KeyShortcut(Key.N, meta = true, shift = true)) { ... }
14+
* ```
15+
*/
16+
data class KeyShortcut(
17+
val key: Key,
18+
val ctrl: Boolean = false,
19+
val shift: Boolean = false,
20+
val alt: Boolean = false,
21+
val meta: Boolean = false,
22+
) {
23+
/**
24+
* Returns the macOS NSEventModifierFlags bitmask for this shortcut.
25+
*/
26+
internal fun toMacModifierMask(): Long {
27+
var mask = 0L
28+
if (meta) mask = mask or (1L shl 20) // NSEventModifierFlagCommand
29+
if (shift) mask = mask or (1L shl 17) // NSEventModifierFlagShift
30+
if (alt) mask = mask or (1L shl 19) // NSEventModifierFlagOption
31+
if (ctrl) mask = mask or (1L shl 18) // NSEventModifierFlagControl
32+
return mask
33+
}
34+
35+
/**
36+
* Returns the key equivalent string for macOS NSMenuItem.
37+
* Lowercase letter for regular key, special Unicode for function/special keys.
38+
*/
39+
internal fun toMacKeyEquivalent(): String = key.macKeyEquivalent
40+
}
41+
42+
/**
43+
* Keyboard keys that can be used in [KeyShortcut].
44+
*
45+
* @property macKeyEquivalent The string value passed to NSMenuItem.setKeyEquivalent on macOS.
46+
*/
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"),
52+
53+
Num0("0"), Num1("1"), Num2("2"), Num3("3"), Num4("4"),
54+
Num5("5"), Num6("6"), Num7("7"), Num8("8"), Num9("9"),
55+
56+
// 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"),
60+
61+
// 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("`"),
87+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ interface TrayMenuBuilder {
1919
*
2020
* @param label The text label for the menu item.
2121
* @param isEnabled Indicates whether the menu item is enabled. Defaults to true.
22+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
2223
* @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda.
2324
*/
2425
fun Item(
2526
label: String,
2627
isEnabled: Boolean = true,
28+
shortcut: KeyShortcut? = null,
2729
onClick: () -> Unit = {},
2830
)
2931

@@ -34,13 +36,15 @@ interface TrayMenuBuilder {
3436
* @param iconContent A Composable function that defines the icon.
3537
* @param iconRenderProperties Properties for rendering the icon. Defaults to 16x16 for menu items.
3638
* @param isEnabled Indicates whether the menu item is enabled. Defaults to true.
39+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
3740
* @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda.
3841
*/
3942
fun Item(
4043
label: String,
4144
iconContent: @Composable () -> Unit,
4245
iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(),
4346
isEnabled: Boolean = true,
47+
shortcut: KeyShortcut? = null,
4448
onClick: () -> Unit = {},
4549
)
4650

@@ -52,6 +56,7 @@ interface TrayMenuBuilder {
5256
* @param iconTint Optional tint color for the icon. If null, adapts to menu theme.
5357
* @param iconRenderProperties Properties for rendering the icon. Defaults to 16x16 for menu items.
5458
* @param isEnabled Indicates whether the menu item is enabled. Defaults to true.
59+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
5560
* @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda.
5661
*/
5762
fun Item(
@@ -60,6 +65,7 @@ interface TrayMenuBuilder {
6065
iconTint: Color? = null,
6166
iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(),
6267
isEnabled: Boolean = true,
68+
shortcut: KeyShortcut? = null,
6369
onClick: () -> Unit = {},
6470
)
6571

@@ -70,13 +76,15 @@ interface TrayMenuBuilder {
7076
* @param icon The Painter to display as icon.
7177
* @param iconRenderProperties Properties for rendering the icon. Defaults to 16x16 for menu items.
7278
* @param isEnabled Indicates whether the menu item is enabled. Defaults to true.
79+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
7380
* @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda.
7481
*/
7582
fun Item(
7683
label: String,
7784
icon: Painter,
7885
iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(),
7986
isEnabled: Boolean = true,
87+
shortcut: KeyShortcut? = null,
8088
onClick: () -> Unit = {},
8189
)
8290

@@ -89,6 +97,7 @@ interface TrayMenuBuilder {
8997
icon: DrawableResource,
9098
iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(),
9199
isEnabled: Boolean = true,
100+
shortcut: KeyShortcut? = null,
92101
onClick: () -> Unit = {},
93102
)
94103

@@ -100,12 +109,14 @@ interface TrayMenuBuilder {
100109
* @param checked The current checked state of the item.
101110
* @param onCheckedChange A lambda function called when the user toggles the item. The new checked state is passed as a parameter.
102111
* @param isEnabled Determines if the checkable item is enabled. Defaults to true.
112+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
103113
*/
104114
fun CheckableItem(
105115
label: String,
106116
checked: Boolean,
107117
onCheckedChange: (Boolean) -> Unit,
108118
isEnabled: Boolean = true,
119+
shortcut: KeyShortcut? = null,
109120
)
110121

111122
/**
@@ -117,6 +128,7 @@ interface TrayMenuBuilder {
117128
* @param checked The current checked state of the item.
118129
* @param onCheckedChange A lambda function called when the user toggles the item.
119130
* @param isEnabled Determines if the checkable item is enabled. Defaults to true.
131+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
120132
*/
121133
fun CheckableItem(
122134
label: String,
@@ -125,6 +137,7 @@ interface TrayMenuBuilder {
125137
checked: Boolean,
126138
onCheckedChange: (Boolean) -> Unit,
127139
isEnabled: Boolean = true,
140+
shortcut: KeyShortcut? = null,
128141
)
129142

130143
/**
@@ -137,6 +150,7 @@ interface TrayMenuBuilder {
137150
* @param checked The current checked state of the item.
138151
* @param onCheckedChange A lambda function called when the user toggles the item.
139152
* @param isEnabled Determines if the checkable item is enabled. Defaults to true.
153+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
140154
*/
141155
fun CheckableItem(
142156
label: String,
@@ -146,6 +160,7 @@ interface TrayMenuBuilder {
146160
checked: Boolean,
147161
onCheckedChange: (Boolean) -> Unit,
148162
isEnabled: Boolean = true,
163+
shortcut: KeyShortcut? = null,
149164
)
150165

151166
/**
@@ -157,6 +172,7 @@ interface TrayMenuBuilder {
157172
* @param checked The current checked state of the item.
158173
* @param onCheckedChange A lambda function called when the user toggles the item.
159174
* @param isEnabled Determines if the checkable item is enabled. Defaults to true.
175+
* @param shortcut Optional keyboard shortcut hint displayed next to the item. Display-only, does not register a hotkey.
160176
*/
161177
fun CheckableItem(
162178
label: String,
@@ -165,6 +181,7 @@ interface TrayMenuBuilder {
165181
checked: Boolean,
166182
onCheckedChange: (Boolean) -> Unit,
167183
isEnabled: Boolean = true,
184+
shortcut: KeyShortcut? = null,
168185
)
169186

170187
/**
@@ -178,6 +195,7 @@ interface TrayMenuBuilder {
178195
checked: Boolean,
179196
onCheckedChange: (Boolean) -> Unit,
180197
isEnabled: Boolean = true,
198+
shortcut: KeyShortcut? = null,
181199
)
182200

183201
/**

0 commit comments

Comments
 (0)