Skip to content

Commit 6e2a19a

Browse files
authored
Merge pull request #389 from kdroidFilter/feat/keyboard-shortcut-hints
feat: add keyboard shortcut hints to tray menu items
2 parents 1f9ac85 + f2dc854 commit 6e2a19a

20 files changed

Lines changed: 486 additions & 167 deletions

File tree

build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ val version =
2222
}
2323

2424
repositories {
25-
mavenCentral()
26-
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
2725
google()
26+
mavenCentral()
27+
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") {
28+
content {
29+
includeGroupByRegex("org\\.jetbrains.*")
30+
}
31+
}
2832
}
2933

3034
tasks.withType<DokkaTask>().configureEach {

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/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: 16 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,21 @@ 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,
367+
id,
368+
shortcut.toLinuxKey(),
369+
shortcut.ctrl,
370+
shortcut.shift,
371+
shortcut.alt,
372+
shortcut.meta,
373+
)
374+
}.onFailure { e -> warnln { "[LinuxTrayManager] Failed to set shortcut: ${e.message}" } }
375+
}
376+
361377
// Submenu
362378
if (item.subMenuItems.isNotEmpty()) {
363379
item.subMenuItems.forEach { sub -> addMenuItemRecursive(id, sub) }

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
@@ -1,6 +1,7 @@
11
package com.kdroid.composetray.lib.mac
22

33
import androidx.compose.runtime.mutableStateOf
4+
import com.kdroid.composetray.menu.api.KeyShortcut
45
import kotlinx.coroutines.CoroutineScope
56
import kotlinx.coroutines.Dispatchers
67
import kotlinx.coroutines.SupervisorJob
@@ -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: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 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, the shortcut renders as right-aligned accelerator text (e.g. Ctrl+S, Ctrl+Shift+N)
11+
* using the native Win32 tab-separated text convention.
12+
*
13+
* Example:
14+
* ```
15+
* Item("Save", shortcut = KeyShortcut(Key.S, meta = true)) { ... }
16+
* Item("New Window", shortcut = KeyShortcut(Key.N, meta = true, shift = true)) { ... }
17+
* ```
18+
*/
19+
data class KeyShortcut(
20+
val key: Key,
21+
val ctrl: Boolean = false,
22+
val shift: Boolean = false,
23+
val alt: Boolean = false,
24+
val meta: Boolean = false,
25+
) {
26+
/**
27+
* Returns the macOS NSEventModifierFlags bitmask for this shortcut.
28+
*/
29+
internal fun toMacModifierMask(): Long {
30+
var mask = 0L
31+
if (meta) mask = mask or (1L shl 20) // NSEventModifierFlagCommand
32+
if (shift) mask = mask or (1L shl 17) // NSEventModifierFlagShift
33+
if (alt) mask = mask or (1L shl 19) // NSEventModifierFlagOption
34+
if (ctrl) mask = mask or (1L shl 18) // NSEventModifierFlagControl
35+
return mask
36+
}
37+
38+
/**
39+
* Returns the key equivalent string for macOS NSMenuItem.
40+
* Lowercase letter for regular key, special Unicode for function/special keys.
41+
*/
42+
internal fun toMacKeyEquivalent(): String = key.macKeyEquivalent
43+
44+
/**
45+
* Returns the DBusMenu key name for Linux shortcut hints.
46+
*/
47+
internal fun toLinuxKey(): String = key.linuxKeyName
48+
49+
/**
50+
* Returns the Windows accelerator display text (e.g. "Ctrl+S", "Ctrl+Shift+N").
51+
* Used with the Win32 tab-separated text convention for menu items.
52+
*/
53+
internal fun toWindowsAcceleratorText(): String {
54+
val parts = mutableListOf<String>()
55+
if (ctrl) parts.add("Ctrl")
56+
if (meta) parts.add("Win")
57+
if (alt) parts.add("Alt")
58+
if (shift) parts.add("Shift")
59+
parts.add(key.windowsDisplayName)
60+
return parts.joinToString("+")
61+
}
62+
}
63+
64+
/**
65+
* Keyboard keys that can be used in [KeyShortcut].
66+
*
67+
* @property macKeyEquivalent The string value passed to NSMenuItem.setKeyEquivalent on macOS.
68+
* @property linuxKeyName The DBusMenu key name string for Linux (follows XKB naming).
69+
* @property windowsDisplayName The display name shown in Win32 menu accelerator text.
70+
*/
71+
enum class Key(
72+
internal val macKeyEquivalent: String,
73+
internal val linuxKeyName: String,
74+
internal val windowsDisplayName: String,
75+
) {
76+
A("a", "a", "A"),
77+
B("b", "b", "B"),
78+
C("c", "c", "C"),
79+
D("d", "d", "D"),
80+
E("e", "e", "E"),
81+
F("f", "f", "F"),
82+
G("g", "g", "G"),
83+
H("h", "h", "H"),
84+
I("i", "i", "I"),
85+
J("j", "j", "J"),
86+
K("k", "k", "K"),
87+
L("l", "l", "L"),
88+
M("m", "m", "M"),
89+
N("n", "n", "N"),
90+
O("o", "o", "O"),
91+
P("p", "p", "P"),
92+
Q("q", "q", "Q"),
93+
R("r", "r", "R"),
94+
S("s", "s", "S"),
95+
T("t", "t", "T"),
96+
U("u", "u", "U"),
97+
V("v", "v", "V"),
98+
W("w", "w", "W"),
99+
X("x", "x", "X"),
100+
Y("y", "y", "Y"),
101+
Z("z", "z", "Z"),
102+
103+
Num0("0", "0", "0"),
104+
Num1("1", "1", "1"),
105+
Num2("2", "2", "2"),
106+
Num3("3", "3", "3"),
107+
Num4("4", "4", "4"),
108+
Num5("5", "5", "5"),
109+
Num6("6", "6", "6"),
110+
Num7("7", "7", "7"),
111+
Num8("8", "8", "8"),
112+
Num9("9", "9", "9"),
113+
114+
// Function keys (AppKit private-use Unicode)
115+
F1("\uF704", "F1", "F1"),
116+
F2("\uF705", "F2", "F2"),
117+
F3("\uF706", "F3", "F3"),
118+
F4("\uF707", "F4", "F4"),
119+
F5("\uF708", "F5", "F5"),
120+
F6("\uF709", "F6", "F6"),
121+
F7("\uF70A", "F7", "F7"),
122+
F8("\uF70B", "F8", "F8"),
123+
F9("\uF70C", "F9", "F9"),
124+
F10("\uF70D", "F10", "F10"),
125+
F11("\uF70E", "F11", "F11"),
126+
F12("\uF70F", "F12", "F12"),
127+
128+
// Special keys
129+
Return("\r", "Return", "Enter"),
130+
Tab("\t", "Tab", "Tab"),
131+
Space(" ", "space", "Space"),
132+
Escape("\u001B", "Escape", "Esc"),
133+
Delete("\u007F", "BackSpace", "Backspace"),
134+
ForwardDelete("\uF728", "Delete", "Del"),
135+
UpArrow("\uF700", "Up", "Up"),
136+
DownArrow("\uF701", "Down", "Down"),
137+
LeftArrow("\uF702", "Left", "Left"),
138+
RightArrow("\uF703", "Right", "Right"),
139+
Home("\uF729", "Home", "Home"),
140+
End("\uF72B", "End", "End"),
141+
PageUp("\uF72C", "Page_Up", "PgUp"),
142+
PageDown("\uF72D", "Page_Down", "PgDn"),
143+
Minus("-", "minus", "-"),
144+
Equal("=", "equal", "="),
145+
LeftBracket("[", "bracketleft", "["),
146+
RightBracket("]", "bracketright", "]"),
147+
Backslash("\\", "backslash", "\\"),
148+
Semicolon(";", "semicolon", ";"),
149+
Quote("'", "apostrophe", "'"),
150+
Comma(",", "comma", ","),
151+
Period(".", "period", "."),
152+
Slash("/", "slash", "/"),
153+
Backquote("`", "grave", "`"),
154+
}

0 commit comments

Comments
 (0)