diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3108a6e..8c88011 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,13 +48,21 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y xvfb xauth libgl1-mesa-dri libglx-mesa0 \
- libxcursor1 libxfixes3 libxtst6
+ libxcursor1 libxfixes3 libxtst6 libdbus-1-3 dbus-daemon xfonts-base
nim check tests/test_x11.nim
nim c tests/test_x11.nim
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 ./tests/test_x11
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
MESA_GL_VERSION_OVERRIDE=3.3 ./tests/test_x11 --unsupported-version
+ - name: Test Linux tray backends
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ nim c tests/test_tray.nim
+ ./tests/test_tray
+ nim c -d:release tests/test_tray.nim
+ ./tests/test_tray
+
# Build native examples.
- run: nim c examples/basic.nim
- run: nim c examples/basic_boxy.nim
diff --git a/README.md b/README.md
index 6ab5e45..92f89cf 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Features:
* Easy polling of keyboard state via `buttonDown[Button]` and more
* IME support (for Chinese, Japanese, etc. text input)
* System clipboard (copy and paste) support
-* Show a system tray icon and menu (Windows only)
+* Show a system tray icon and menu (Windows and Linux)
* Non-blocking HTTP requests and WebSockets
## Basic Example
@@ -52,6 +52,18 @@ while not window.closeRequested:
[Check out more examples here.](https://github.com/treeform/windy/tree/master/examples)
+## Tray icons
+
+`showTrayIcon(image, tooltip, onClick, menu)` shows one icon;
+calling it again replaces the icon and menu. Call `pollEvents()` to process
+interaction, and `hideTrayIcon()` to remove it. See [examples/tray.nim](examples/tray.nim).
+
+Linux selects an available StatusNotifierItem host, or an XEmbed tray when
+SNI is unavailable. It waits if neither exists and recovers when the tray host
+restarts. SNI uses the optional `libdbus-1.so.3` runtime library. XEmbed uses
+Xlib with a basic popup menu; SNI menus follow the desktop's presentation.
+Tray-only Linux applications do not need to create a window or OpenGL context.
+
## CPU Pixels
On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window without an
diff --git a/examples/tray.nim b/examples/tray.nim
index 2700161..8ff484a 100644
--- a/examples/tray.nim
+++ b/examples/tray.nim
@@ -1,7 +1,6 @@
import pixie, windy
-when defined(windows):
- # Tray API only currently supported on Windows
+when defined(windows) or (defined(linux) and not defined(emscripten)):
let window = newWindow("Windy Tray Icon", ivec2(1280, 800))
window.makeContextCurrent()
diff --git a/src/windy/common.nim b/src/windy/common.nim
index 30e8fde..572cc4b 100644
--- a/src/windy/common.nim
+++ b/src/windy/common.nim
@@ -91,6 +91,18 @@ type
DecoratedResizable, Decorated, Undecorated, Transparent
Callback* = proc()
+ TrayMenuEntryKind* = enum
+ TrayMenuOption, TrayMenuSeparator
+
+ TrayMenuEntry* = object
+ ## A tray menu option or separator. Option labels are literal UTF-8 text.
+ case kind*: TrayMenuEntryKind
+ of TrayMenuOption:
+ text*: string
+ onClick*: Callback
+ of TrayMenuSeparator:
+ discard
+
ButtonCallback* = proc(button: Button)
RuneCallback* = proc(rune: Rune)
TextInputCallback* = proc(text: string)
diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim
index 9ae5f62..91fdf2c 100644
--- a/src/windy/platforms/linux/platform.nim
+++ b/src/windy/platforms/linux/platform.nim
@@ -1,5 +1,6 @@
-import ../../common, ./backends
+import ../../common, ./backends, ./tray
export backends
+export tray except pollTrayEvents
when not HasDirect:
import ./x11
@@ -61,6 +62,7 @@ else:
if selected == X11Backend:
when HasX11: x11.pollEvents()
else:
+ pollTrayEvents()
direct.pollEvents()
pollHttp()
diff --git a/src/windy/platforms/linux/tray.nim b/src/windy/platforms/linux/tray.nim
new file mode 100644
index 0000000..64b4deb
--- /dev/null
+++ b/src/windy/platforms/linux/tray.nim
@@ -0,0 +1,105 @@
+import
+ std/unicode,
+ pixie,
+ ../../common,
+ ./backends,
+ ./tray/[state, sni]
+
+when HasX11:
+ import ./tray/xembed
+
+type
+ TrayBackend = enum
+ noTray, sniTray, xembedTray
+
+var
+ requested: TrayState
+ busTray: SniTray
+ backend: TrayBackend
+ revision: uint32
+ nextMenuId = 1.int32
+
+when HasX11:
+ var plugTray: XembedTray
+
+proc hideTrayIcon*() =
+ ## Removes the Linux tray icon and cancels pending registration. Safe to repeat.
+ requested = nil
+ backend = noTray
+ busTray.close()
+ when HasX11:
+ plugTray.close()
+ plugTray = nil
+ busTray = nil
+
+proc showTrayIcon*(icon: Image, tooltip: string, onClick: Callback,
+ menu: seq[TrayMenuEntry] = @[]) =
+ ## Shows or replaces one tray icon. Call pollEvents to process tray interaction.
+ ## Waits for a tray host when none is present; prefers SNI, then XEmbed.
+ ## Labels are literal UTF-8. SNI hosts determine how activation is presented.
+ if icon == nil or icon.width <= 0 or icon.height <= 0:
+ raise newException(WindyError, "A tray icon must contain pixels")
+ if '\0' in tooltip or tooltip.validateUtf8() != -1:
+ raise newException(WindyError, "A tray tooltip must be UTF-8 without null bytes")
+ for entry in menu:
+ if entry.kind == TrayMenuOption and
+ ('\0' in entry.text or entry.text.validateUtf8() != -1):
+ raise newException(WindyError, "A tray menu label must be UTF-8 without null bytes")
+ if menu.len > (high(int32) - nextMenuId).int:
+ raise newException(WindyError, "Tray menu IDs exhausted")
+ inc revision
+ let state = TrayState(icon: icon.copy(), tooltip: tooltip,
+ onClick: onClick, revision: revision, firstMenuId: nextMenuId)
+ nextMenuId += menu.len.int32
+ # Own the sequence so later application edits cannot change a published menu.
+ for entry in menu: state.menu.add entry
+ if requested == nil:
+ requested = state
+ try:
+ busTray = newSniTray(state)
+ when HasX11:
+ plugTray = newXembedTray(state)
+ except:
+ hideTrayIcon()
+ raise
+ else:
+ requested = state
+ busTray.update(state)
+ when HasX11:
+ plugTray.update(state)
+
+proc pollTrayEvents*() =
+ ## Pumps both host monitors and dispatches callbacks for the current snapshot.
+ if requested == nil: return
+ let snapshot = requested
+ var callbacks = busTray.poll()
+ when HasX11:
+ callbacks.add plugTray.poll()
+ if backend == sniTray and not busTray.hostAvailable and not busTray.selecting:
+ # Closing the connection unregisters even if the watcher itself still exists.
+ let failedOwner = busTray.failedOwner
+ busTray.close()
+ busTray = newSniTray(requested, failedOwner)
+ backend = noTray
+ when HasX11:
+ if backend == xembedTray and not plugTray.available:
+ plugTray.setActive(false)
+ backend = noTray
+ if backend == noTray:
+ if busTray.hostAvailable:
+ backend = sniTray
+ busTray.setActive(true)
+ elif not busTray.selecting:
+ when HasX11:
+ if plugTray.available:
+ backend = xembedTray
+ plugTray.setActive(true)
+ elif backend == sniTray:
+ busTray.setActive(true)
+ when HasX11:
+ if backend == xembedTray:
+ # Redock if the manager retained ownership but recreated the plug.
+ plugTray.setActive(true)
+ for callback in callbacks:
+ if requested != snapshot: break
+ callback()
diff --git a/src/windy/platforms/linux/tray/sni.nim b/src/windy/platforms/linux/tray/sni.nim
new file mode 100644
index 0000000..0e9fef8
--- /dev/null
+++ b/src/windy/platforms/linux/tray/sni.nim
@@ -0,0 +1,450 @@
+import
+ std/[monotimes, os, strutils, times, unicode],
+ pixie,
+ ../../../common,
+ ./state,
+ ./sni/dbus
+
+type
+ SniTray* = ref object
+ connection: Connection
+ state: TrayState
+ owner: string
+ querySerial, ownerSerial, registerSerial: uint32
+ hostAvailable*, registered*: bool
+ selecting*: bool
+ deadline: MonoTime
+ blockedOwner: string
+ failedOwner*: string
+
+const
+ Watcher = "org.kde.StatusNotifierWatcher"
+ WatcherPath = "/StatusNotifierWatcher"
+ Item = "org.kde.StatusNotifierItem"
+ ItemPath = "/StatusNotifierItem"
+ Menu = "com.canonical.dbusmenu"
+ MenuPath = "/Menu"
+ ReplyTimeoutSeconds = 2
+ EventBatchSize = 64
+ Properties = "org.freedesktop.DBus.Properties"
+ ItemProperties = [
+ ("Category", "s"), ("Id", "s"), ("Title", "s"), ("Status", "s"),
+ ("WindowId", "u"), ("IconName", "s"), ("IconPixmap", "a(iiay)"),
+ ("OverlayIconName", "s"), ("OverlayIconPixmap", "a(iiay)"),
+ ("AttentionIconName", "s"), ("AttentionIconPixmap", "a(iiay)"),
+ ("AttentionMovieName", "s"), ("ToolTip", "(sa(iiay)ss)"),
+ ("ItemIsMenu", "b"), ("Menu", "o")]
+ MenuProperties = [("Version", "u"), ("Status", "s"),
+ ("TextDirection", "s"), ("IconThemePath", "as")]
+ IntrospectionHeader = "" &
+ "" &
+ "" &
+ ""
+ ItemMethods = "" &
+ "" &
+ "" &
+ "" &
+ ""
+ MenuMethods = "" &
+ "" &
+ "" &
+ "" &
+ "" &
+ "" &
+ "" &
+ "" &
+ ""
+
+proc introspection(isMenu: bool): string =
+ ## Describes only the interfaces implemented at the requested object path.
+ result = IntrospectionHeader
+ result.add ""
+ if isMenu:
+ for (name, signature) in MenuProperties:
+ result.add ""
+ result.add MenuMethods
+ else:
+ for (name, signature) in ItemProperties:
+ result.add ""
+ result.add ItemMethods
+ result.add ""
+
+proc pixmaps(i: var Iter, state: TrayState, present: bool) =
+ ## Serializes straight-alpha ARGB pixels in network byte order.
+ container(i, 'a', "(iiay)", images):
+ if present:
+ container(images, 'r', nil, image):
+ image.addInt state.icon.width.int32
+ image.addInt state.icon.height.int32
+ container(image, 'a', "y", bytes):
+ for pixel in state.icon.data:
+ let c = pixel.rgba()
+ bytes.addByte c.a
+ bytes.addByte c.r
+ bytes.addByte c.g
+ bytes.addByte c.b
+
+proc propertyValue(i: var Iter, state: TrayState, name, signature: string) =
+ ## Writes a supported property as a D-Bus variant.
+ container(i, 'v', signature.cstring, value):
+ case name
+ of "Category": value.addString "ApplicationStatus"
+ of "Id":
+ let name = getAppFilename().extractFilename()
+ value.addString(if name.validateUtf8() == -1: name else: "windy")
+ of "Title": value.addString state.tooltip
+ of "Status": value.addString "Active"
+ of "WindowId": value.addUint 0
+ of "Version": value.addUint 4
+ of "ItemIsMenu": value.addBool false
+ of "Menu": value.addString MenuPath, 'o'
+ of "TextDirection": value.addString "ltr"
+ of "IconThemePath":
+ container(value, 'a', "s", paths): discard
+ of "ToolTip":
+ container(value, 'r', nil, tooltip):
+ tooltip.addString ""
+ tooltip.pixmaps(state, false)
+ tooltip.addString state.tooltip
+ tooltip.addString ""
+ of "IconPixmap", "AttentionIconPixmap", "OverlayIconPixmap":
+ value.pixmaps(state, name == "IconPixmap")
+ else: value.addString ""
+
+proc error(tray: SniTray, call: Message, name, text: string) =
+ ## Replies with a protocol error instead of passing bad arguments to libdbus.
+ discard tray.connection.send(messageNewError(call, ("org.freedesktop.DBus.Error." & name).cstring, text.cstring))
+
+proc validId(state: TrayState, id: int32): bool =
+ ## Accepts the root and currently published menu IDs.
+ id == 0 or (id >= state.firstMenuId and id - state.firstMenuId < state.menu.len)
+
+proc strings(i: var Iter): seq[string] =
+ ## Copies a validated array of strings before the native message is released.
+ var child = i.recurse()
+ while messageIterGetArgType(child.addr) == 's'.cint:
+ result.add child.stringValue()
+ child.next()
+
+proc ids(i: var Iter): seq[int32] =
+ ## Copies a validated array of menu IDs.
+ var child = i.recurse()
+ while messageIterGetArgType(child.addr) == 'i'.cint:
+ result.add child.intValue()
+ child.next()
+
+proc menuProperty(i: var Iter, state: TrayState, id: int32, name: string): bool =
+ ## Writes one property for the deliberately flat menu model.
+ let index = id - state.firstMenuId
+ let separator = id > 0 and state.menu[index].kind == TrayMenuSeparator
+ case name
+ of "label":
+ container(i, 'v', "s", value):
+ value.addString(if id > 0 and not separator: state.menu[index].text.replace("_", "__") else: "")
+ of "type":
+ container(i, 'v', "s", value):
+ value.addString(if separator: "separator" else: "standard")
+ of "enabled", "visible":
+ container(i, 'v', "b", value): value.addBool true
+ of "children-display":
+ container(i, 'v', "s", value): value.addString(if id == 0: "submenu" else: "")
+ else: return false
+ true
+
+proc menuProperties(i: var Iter, state: TrayState, id: int32, requested: seq[string]) =
+ ## Emits requested properties, or all properties for an empty filter.
+ container(i, 'a', "{sv}", props):
+ for name in ["label", "type", "enabled", "visible", "children-display"]:
+ if requested.len == 0 or name in requested:
+ container(props, 'e', nil, entry):
+ entry.addString name
+ discard entry.menuProperty(state, id, name)
+
+proc layout(i: var Iter, state: TrayState, id, depth: int32, requested: seq[string]) =
+ ## Emits the root and its flat children, respecting the requested depth.
+ container(i, 'r', nil, node):
+ node.addInt id
+ node.menuProperties(state, id, requested)
+ container(node, 'a', "v", children):
+ if id == 0 and depth != 0:
+ for index in 0.. 0 and tray.state.validId(id):
+ let callback = tray.state.menuCallback(id - tray.state.firstMenuId + 1)
+ if callback != nil: callbacks.add callback
+ if member == "Event":
+ event(args)
+ else:
+ var events = args.recurse()
+ container(outArgs, 'a', "i", errors):
+ while messageIterGetArgType(events.addr) == 'r'.cint:
+ var entry = events.recurse()
+ let id = entry.intValue()
+ if not tray.state.validId(id): errors.addInt id
+ else: event(entry)
+ events.next()
+ else:
+ tray.error(call, "UnknownMethod", "Unknown menu method")
+ return
+ else:
+ tray.error(call, "UnknownInterface", "Unknown tray interface")
+ return
+ checked connectionSend(tray.connection, reply, nil)
+
+proc queryHost(tray: SniTray) =
+ ## Asynchronously resolves the watcher before trusting its signals or replies.
+ let call = requireMessage(messageNewMethodCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner"))
+ var args = call.writer()
+ args.addString Watcher
+ tray.ownerSerial = tray.connection.send(call)
+ tray.selecting = true
+ tray.deadline = getMonoTime() + initDuration(seconds = ReplyTimeoutSeconds)
+
+proc newSniTray*(state: TrayState, blockedOwner = ""): SniTray =
+ ## Starts optional session-bus discovery without requiring an X11 display.
+ result = SniTray(state: state, blockedOwner: blockedOwner)
+ if not loadDbus(): return
+ result.connection = busGetPrivate(0, nil)
+ if result.connection == nil: return
+ try:
+ connectionSetExitOnDisconnect(result.connection, 0)
+ busAddMatch(result.connection, "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.kde.StatusNotifierWatcher'", nil)
+ busAddMatch(result.connection, "type='signal',interface='org.kde.StatusNotifierWatcher'", nil)
+ result.queryHost()
+ except:
+ connectionClose(result.connection)
+ connectionUnref(result.connection)
+ result.connection = nil
+ raise
+
+proc setActive*(tray: SniTray, active: bool) =
+ ## Registers only the selected backend, using the connection's unique bus name.
+ if active and tray.hostAvailable and not tray.selecting and
+ tray.registerSerial == 0 and not tray.registered:
+ let call = requireMessage(messageNewMethodCall(Watcher, WatcherPath, Watcher, "RegisterStatusNotifierItem"))
+ var args = call.writer()
+ args.addString ItemPath
+ tray.registerSerial = tray.connection.send(call)
+ tray.deadline = getMonoTime() + initDuration(seconds = ReplyTimeoutSeconds)
+
+proc update*(tray: SniTray, state: TrayState) =
+ ## Replaces the snapshot and notifies hosts that cached content changed.
+ tray.state = state
+ if tray.connection == nil: return
+ for name in ["NewIcon", "NewToolTip", "NewTitle"]:
+ discard tray.connection.send(messageNewSignal(ItemPath, Item, name.cstring))
+ let signal = requireMessage(messageNewSignal(MenuPath, Menu, "LayoutUpdated"))
+ var args = signal.writer()
+ args.addUint state.revision
+ args.addInt 0
+ discard tray.connection.send(signal)
+
+proc poll*(tray: SniTray): seq[Callback] =
+ ## Processes a bounded batch without waiting for bus traffic or method replies.
+ if tray.connection == nil: return
+ if (tray.selecting or tray.registerSerial != 0) and getMonoTime() > tray.deadline:
+ if tray.registerSerial != 0:
+ tray.failedOwner = tray.owner
+ tray.selecting = false
+ tray.hostAvailable = false
+ tray.ownerSerial = 0
+ tray.querySerial = 0
+ tray.registerSerial = 0
+ if connectionReadWrite(tray.connection, 0) == 0:
+ tray.hostAvailable = false
+ tray.registered = false
+ tray.selecting = false
+ return
+ for unused in 0.. 0:
+ let call = requireMessage(messageNewMethodCall(tray.owner.cstring, WatcherPath, Properties, "Get"))
+ var query = call.writer()
+ query.addString Watcher
+ query.addString "IsStatusNotifierHostRegistered"
+ tray.querySerial = tray.connection.send(call)
+ else: tray.selecting = false
+ elif serial == tray.querySerial:
+ tray.querySerial = 0
+ tray.selecting = false
+ if kind == 2 and $messageGetSignature(message) == "v":
+ var value = args.recurse()
+ if messageIterGetArgType(value.addr) == 'b'.cint:
+ var available: uint32
+ messageIterGetBasic(value.addr, available.addr)
+ tray.hostAvailable = available != 0 and tray.owner != tray.blockedOwner
+ elif serial == tray.registerSerial:
+ tray.registerSerial = 0
+ tray.registered = kind == 2
+ if kind != 2:
+ tray.hostAvailable = false
+ tray.failedOwner = tray.owner
+ elif kind == 4:
+ let iface = $messageGetInterface(message)
+ if iface == "org.freedesktop.DBus" and
+ $messageGetMember(message) == "NameOwnerChanged" and
+ $messageGetSender(message) == "org.freedesktop.DBus" and
+ $messageGetSignature(message) == "sss" and args.stringValue() == Watcher:
+ tray.registered = false
+ tray.registerSerial = 0
+ tray.blockedOwner = ""
+ tray.querySerial = 0
+ tray.queryHost()
+ elif iface == Watcher and $messageGetSender(message) == tray.owner:
+ if $messageGetMember(message) in ["StatusNotifierHostRegistered", "StatusNotifierHostUnregistered"]:
+ tray.registered = false
+ tray.blockedOwner = ""
+ tray.queryHost()
+ elif kind == 1:
+ tray.methodCall(message, result)
+ finally:
+ messageUnref(message)
+
+proc close*(tray: SniTray) =
+ ## Removes registration by closing the private connection and releases bus state.
+ if tray != nil and tray.connection != nil:
+ connectionClose(tray.connection)
+ connectionUnref(tray.connection)
+ tray.connection = nil
+ if tray != nil:
+ tray.registered = false
+ tray.hostAvailable = false
diff --git a/src/windy/platforms/linux/tray/sni/dbus.nim b/src/windy/platforms/linux/tray/sni/dbus.nim
new file mode 100644
index 0000000..8667c89
--- /dev/null
+++ b/src/windy/platforms/linux/tray/sni/dbus.nim
@@ -0,0 +1,176 @@
+import
+ std/dynlib,
+ ../../../../common
+
+type
+ Connection* = ptr object
+ Message* = ptr object
+ Iter* = object
+ # Public libdbus iterator storage; pointer-sized fields preserve the C ABI.
+ dummy1, dummy2: pointer
+ dummy3: uint32
+ dummy4, dummy5, dummy6, dummy7, dummy8, dummy9, dummy10, dummy11: cint
+ pad1: cint
+ pad2, pad3: pointer
+
+var
+ library: LibHandle
+ busGetPrivate*: proc(kind: cint, error: pointer): Connection {.cdecl.}
+ busAddMatch*: proc(c: Connection, rule: cstring, error: pointer) {.cdecl.}
+ connectionSetExitOnDisconnect*: proc(c: Connection, enabled: uint32) {.cdecl.}
+ connectionReadWrite*: proc(c: Connection, timeout: cint): uint32 {.cdecl.}
+ connectionPopMessage*: proc(c: Connection): Message {.cdecl.}
+ connectionSend*: proc(c: Connection, m: Message, serial: ptr uint32): uint32 {.cdecl.}
+ connectionClose*: proc(c: Connection) {.cdecl.}
+ connectionUnref*: proc(c: Connection) {.cdecl.}
+ messageNewMethodCall*: proc(dest, path, iface, name: cstring): Message {.cdecl.}
+ messageNewMethodReturn*: proc(m: Message): Message {.cdecl.}
+ messageNewError*: proc(m: Message, name, text: cstring): Message {.cdecl.}
+ messageNewSignal*: proc(path, iface, name: cstring): Message {.cdecl.}
+ messageUnref*: proc(m: Message) {.cdecl.}
+ messageGetType*: proc(m: Message): cint {.cdecl.}
+ messageGetReplySerial*: proc(m: Message): uint32 {.cdecl.}
+ messageGetPath*: proc(m: Message): cstring {.cdecl.}
+ messageGetInterface*: proc(m: Message): cstring {.cdecl.}
+ messageGetMember*: proc(m: Message): cstring {.cdecl.}
+ messageGetSender*: proc(m: Message): cstring {.cdecl.}
+ messageGetSignature*: proc(m: Message): cstring {.cdecl.}
+ messageIterInitAppend*: proc(m: Message, i: ptr Iter) {.cdecl.}
+ messageIterInit*: proc(m: Message, i: ptr Iter): uint32 {.cdecl.}
+ messageIterGetArgType*: proc(i: ptr Iter): cint {.cdecl.}
+ messageIterGetBasic*: proc(i: ptr Iter, value: pointer) {.cdecl.}
+ messageIterNext*: proc(i: ptr Iter): uint32 {.cdecl.}
+ messageIterRecurse*: proc(i, child: ptr Iter) {.cdecl.}
+ messageIterAppendBasic*: proc(i: ptr Iter, kind: cint, value: pointer): uint32 {.cdecl.}
+ messageIterOpenContainer*: proc(i: ptr Iter, kind: cint, signature: cstring, child: ptr Iter): uint32 {.cdecl.}
+ messageIterCloseContainer*: proc(i, child: ptr Iter): uint32 {.cdecl.}
+ messageIterAbandonContainer*: proc(i, child: ptr Iter) {.cdecl.}
+
+proc loadDbus*(): bool =
+ ## Loads the optional runtime library without requiring development headers.
+ if library != nil:
+ return true
+ let handle = loadLib("libdbus-1.so.3")
+ if handle == nil:
+ return false
+ template bindProc(name: untyped, symbol: string) =
+ name = cast[typeof(name)](symAddr(handle, symbol))
+ if name == nil:
+ unloadLib(handle)
+ return false
+ bindProc(busGetPrivate, "dbus_bus_get_private")
+ bindProc(busAddMatch, "dbus_bus_add_match")
+ bindProc(connectionSetExitOnDisconnect, "dbus_connection_set_exit_on_disconnect")
+ bindProc(connectionReadWrite, "dbus_connection_read_write")
+ bindProc(connectionPopMessage, "dbus_connection_pop_message")
+ bindProc(connectionSend, "dbus_connection_send")
+ bindProc(connectionClose, "dbus_connection_close")
+ bindProc(connectionUnref, "dbus_connection_unref")
+ bindProc(messageNewMethodCall, "dbus_message_new_method_call")
+ bindProc(messageNewMethodReturn, "dbus_message_new_method_return")
+ bindProc(messageNewError, "dbus_message_new_error")
+ bindProc(messageNewSignal, "dbus_message_new_signal")
+ bindProc(messageUnref, "dbus_message_unref")
+ bindProc(messageGetType, "dbus_message_get_type")
+ bindProc(messageGetReplySerial, "dbus_message_get_reply_serial")
+ bindProc(messageGetPath, "dbus_message_get_path")
+ bindProc(messageGetInterface, "dbus_message_get_interface")
+ bindProc(messageGetMember, "dbus_message_get_member")
+ bindProc(messageGetSender, "dbus_message_get_sender")
+ bindProc(messageGetSignature, "dbus_message_get_signature")
+ bindProc(messageIterInitAppend, "dbus_message_iter_init_append")
+ bindProc(messageIterInit, "dbus_message_iter_init")
+ bindProc(messageIterGetArgType, "dbus_message_iter_get_arg_type")
+ bindProc(messageIterGetBasic, "dbus_message_iter_get_basic")
+ bindProc(messageIterNext, "dbus_message_iter_next")
+ bindProc(messageIterRecurse, "dbus_message_iter_recurse")
+ bindProc(messageIterAppendBasic, "dbus_message_iter_append_basic")
+ bindProc(messageIterOpenContainer, "dbus_message_iter_open_container")
+ bindProc(messageIterCloseContainer, "dbus_message_iter_close_container")
+ bindProc(messageIterAbandonContainer, "dbus_message_iter_abandon_container")
+ library = handle
+ true
+
+proc checked*(ok: uint32) =
+ ## Turns allocation failures into a Nim exception before using an invalid message.
+ if ok == 0:
+ raise newException(WindyError, "Unable to allocate D-Bus message storage")
+
+proc requireMessage*(m: Message): Message =
+ ## Checks message allocation before dereferencing the native pointer.
+ if m == nil:
+ raise newException(WindyError, "Unable to allocate D-Bus message")
+ m
+
+proc writer*(m: Message): Iter =
+ ## Creates an append iterator for a message.
+ messageIterInitAppend(m, result.addr)
+
+proc reader*(m: Message): Iter =
+ ## Creates a read iterator, including for an empty message.
+ discard messageIterInit(m, result.addr)
+
+proc addString*(i: var Iter, value: string, kind = 's') =
+ ## Appends a string, object path, or signature.
+ var value = value.cstring
+ checked messageIterAppendBasic(i.addr, kind.cint, value.addr)
+
+proc addInt*(i: var Iter, value: int32) =
+ ## Appends a signed 32-bit integer.
+ var value = value
+ checked messageIterAppendBasic(i.addr, 'i'.cint, value.addr)
+
+proc addUint*(i: var Iter, value: uint32) =
+ ## Appends an unsigned 32-bit integer.
+ var value = value
+ checked messageIterAppendBasic(i.addr, 'u'.cint, value.addr)
+
+proc addBool*(i: var Iter, value: bool) =
+ ## Appends the 32-bit D-Bus boolean representation.
+ var value = uint32(value)
+ checked messageIterAppendBasic(i.addr, 'b'.cint, value.addr)
+
+proc addByte*(i: var Iter, value: uint8) =
+ ## Appends one byte.
+ var value = value
+ checked messageIterAppendBasic(i.addr, 'y'.cint, value.addr)
+
+template container*(parent: var Iter, kind: char, signature: cstring,
+ child: untyped, body: untyped) =
+ block:
+ var child: Iter
+ checked messageIterOpenContainer(parent.addr, kind.cint, signature, child.addr)
+ try:
+ body
+ except:
+ messageIterAbandonContainer(parent.addr, child.addr)
+ raise
+ checked messageIterCloseContainer(parent.addr, child.addr)
+
+proc stringValue*(i: var Iter): string =
+ ## Reads a string only after verifying its native type.
+ if messageIterGetArgType(i.addr) notin ['s'.cint, 'o'.cint, 'g'.cint]:
+ return
+ var value: cstring
+ messageIterGetBasic(i.addr, value.addr)
+ $value
+
+proc intValue*(i: var Iter): int32 =
+ ## Reads a signed integer from a validated argument.
+ if messageIterGetArgType(i.addr) == 'i'.cint:
+ messageIterGetBasic(i.addr, result.addr)
+
+proc next*(i: var Iter) =
+ ## Advances to the next argument.
+ discard messageIterNext(i.addr)
+
+proc recurse*(i: var Iter): Iter =
+ ## Opens a container for reading after its type has been checked.
+ if messageIterGetArgType(i.addr) in ['a'.cint, 'r'.cint, 'v'.cint, 'e'.cint]:
+ messageIterRecurse(i.addr, result.addr)
+
+proc send*(c: Connection, m: Message): uint32 =
+ ## Queues a message and releases its reference without blocking on a reply.
+ discard requireMessage(m)
+ defer: messageUnref(m)
+ checked connectionSend(c, m, result.addr)
diff --git a/src/windy/platforms/linux/tray/state.nim b/src/windy/platforms/linux/tray/state.nim
new file mode 100644
index 0000000..48971bf
--- /dev/null
+++ b/src/windy/platforms/linux/tray/state.nim
@@ -0,0 +1,19 @@
+import
+ pixie,
+ ../../../common
+
+type
+ TrayState* = ref object
+ ## One immutable snapshot shared by the selected tray backend.
+ icon*: Image
+ tooltip*: string
+ onClick*: Callback
+ menu*: seq[TrayMenuEntry]
+ revision*: uint32
+ firstMenuId*: int32
+
+proc menuCallback*(state: TrayState, id: int32): Callback =
+ ## Returns the action for a valid option ID, ignoring roots and separators.
+ if id > 0 and id <= state.menu.len and
+ state.menu[id - 1].kind == TrayMenuOption:
+ result = state.menu[id - 1].onClick
diff --git a/src/windy/platforms/linux/tray/xembed.nim b/src/windy/platforms/linux/tray/xembed.nim
new file mode 100644
index 0000000..3ca7402
--- /dev/null
+++ b/src/windy/platforms/linux/tray/xembed.nim
@@ -0,0 +1,476 @@
+import
+ std/[monotimes, times],
+ pixie,
+ ../../../common,
+ ../x11/keysym,
+ ./state,
+ ./xembed/xlib
+
+type
+ XembedTray* = ref object
+ display: Display
+ root, manager, icon, popup, tooltipWindow: xlib.Window
+ selection, managerAtom, opcode, embed, embedInfo: Atom
+ visual: ptr Visual
+ depth: cint
+ colormap: Colormap
+ gc, popupGc: GC
+ fonts: FontSet
+ state: TrayState
+ width, height, popupWidth, popupHeight: int
+ hovered: int
+ hoverStart: MonoTime
+ pointerInside: bool
+ errorCode: int
+ available*: bool
+
+const
+ RowHeight = 26
+ SeparatorHeight = 9
+ MenuPadding = 6
+ TooltipDelayMs = 700
+ EventBatchSize = 64
+ MaximumIconExtent = 1024
+ CwBackPixmap = 1.culong
+ CwBackPixel = 2.culong
+ CwBorderPixel = 8.culong
+ CwOverrideRedirect = 512.culong
+ CwEventMask = 2048.culong
+ CwColormap = 8192.culong
+ ZPixmap = 2.cint
+ GetImageRequest = 73
+
+var
+ errorTray: XembedTray
+ previousErrorHandler: ErrorHandleProc
+
+proc trayError(d: Display, event: ptr XErrorEvent): cint {.cdecl.} =
+ ## Captures only this private connection's errors, preserving other clients.
+ if errorTray != nil and d == errorTray.display:
+ # The panel can unmap or resize the plug between exposure and XGetImage.
+ if event.errorCode.int == badMatch.ord and event.requestCode.int == GetImageRequest:
+ return 0
+ if errorTray.errorCode == 0 or event.errorCode.int notin [badWindow.ord, badDrawable.ord]:
+ errorTray.errorCode = event.errorCode.int
+ elif previousErrorHandler != nil:
+ return previousErrorHandler(d, event)
+
+template withErrors(tray: XembedTray, body: untyped) =
+ block:
+ errorTray = tray
+ previousErrorHandler = XSetErrorHandler(trayError)
+ try:
+ body
+ finally:
+ # Drain errors before restoring the process-wide handler.
+ tray.display.XSync()
+ discard XSetErrorHandler(previousErrorHandler)
+ errorTray = nil
+ if tray.errorCode notin [0, badWindow.ord, badDrawable.ord]:
+ let code = tray.errorCode
+ tray.errorCode = 0
+ raise newException(WindyError, "XEmbed X11 error: " & $code)
+ tray.errorCode = 0
+
+proc atom(tray: XembedTray, name: string): Atom =
+ ## Interns an atom on the private display.
+ tray.display.XInternAtom(name.cstring, 0)
+
+proc hidePopup(tray: XembedTray) =
+ ## Releases menu grabs before destroying the popup.
+ if tray.popup != 0:
+ tray.display.XUngrabPointer(CurrentTime)
+ discard tray.display.XUngrabKeyboard(CurrentTime)
+ tray.display.XDestroyWindow(tray.popup)
+ tray.popup = 0
+ if tray.popupGc != nil:
+ tray.display.XFreeGC(tray.popupGc)
+ tray.popupGc = nil
+
+proc hideTooltip(tray: XembedTray) =
+ ## Removes a visible tooltip when interaction or embedding changes.
+ if tray.tooltipWindow != 0:
+ tray.display.XDestroyWindow(tray.tooltipWindow)
+ tray.tooltipWindow = 0
+
+proc removeIcon(tray: XembedTray) =
+ ## Releases the plug and its resources while keeping manager discovery alive.
+ tray.hidePopup()
+ tray.hideTooltip()
+ if tray.icon != 0:
+ tray.display.XDestroyWindow(tray.icon)
+ tray.icon = 0
+ if tray.gc != nil:
+ tray.display.XFreeGC(tray.gc)
+ tray.gc = nil
+ if tray.colormap != 0:
+ tray.display.XFreeColormap(tray.colormap)
+ tray.colormap = 0
+ tray.pointerInside = false
+
+proc managerChanged(tray: XembedTray) =
+ ## Resolves the manager after startup or an ownership announcement.
+ let owner = tray.display.XGetSelectionOwner(tray.selection)
+ if owner != tray.manager:
+ tray.removeIcon()
+ tray.manager = owner
+ if owner != 0:
+ tray.display.XSelectInput(owner, StructureNotifyMask)
+ tray.available = owner != 0
+
+proc newXembedTray*(state: TrayState): XembedTray =
+ ## Starts XEmbed discovery without initializing Windy's window or GL backend.
+ result = XembedTray(state: state, hovered: -1)
+ result.display = XOpenDisplay(nil)
+ if result.display == nil: return
+ try:
+ result.root = result.display.defaultRootWindow()
+ result.selection = result.atom("_NET_SYSTEM_TRAY_S" & $result.display.defaultScreen())
+ result.managerAtom = result.atom("MANAGER")
+ result.opcode = result.atom("_NET_SYSTEM_TRAY_OPCODE")
+ result.embed = result.atom("_XEMBED")
+ result.embedInfo = result.atom("_XEMBED_INFO")
+ withErrors(result):
+ result.display.XSelectInput(result.root, StructureNotifyMask)
+ result.managerChanged()
+ except:
+ discard result.display.XCloseDisplay()
+ result.display = nil
+ raise
+
+proc visualInfo(tray: XembedTray) =
+ ## Uses the manager's requested visual, falling back to the screen default.
+ let screen = tray.display.screen(tray.display.defaultScreen())
+ tray.visual = screen.rootVisual
+ tray.depth = screen.rootDepth
+ var
+ kind: Atom
+ format: cint
+ length, remaining: culong
+ data: cstring
+ tray.display.XGetWindowProperty(tray.manager, tray.atom("_NET_SYSTEM_TRAY_VISUAL"),
+ 0, 1, false, AnyPropertyType, kind.addr, format.addr, length.addr, remaining.addr, data.addr)
+ if data != nil:
+ defer: XFree(data)
+ if format == 32 and length == 1:
+ var info = XVisualInfo(visualid: cast[ptr culong](data)[])
+ var count: cint
+ let found = tray.display.XGetVisualInfo(1, info.addr, count.addr)
+ if found != nil:
+ tray.visual = found.visual
+ tray.depth = found.depth
+ XFree(found)
+
+proc dock(tray: XembedTray) =
+ ## Creates a mapped XEmbed plug and requests docking with the current manager.
+ tray.visualInfo()
+ tray.width = 24
+ tray.height = 24
+ tray.colormap = tray.display.XCreateColormap(tray.root, tray.visual, 0)
+ var attrs = XSetWindowAttributes(
+ colormap: tray.colormap, borderPixel: 0,
+ eventMask: ExposureMask or StructureNotifyMask or ButtonReleaseMask or
+ EnterWindowMask or LeaveWindowMask)
+ # ParentRelative is valid when the embedder uses the same visual/depth.
+ let screen = tray.display.screen(tray.display.defaultScreen())
+ var mask = CwColormap or CwBorderPixel or CwEventMask
+ if tray.depth == screen.rootDepth:
+ attrs.backgroundPixmap = ParentRelative
+ mask = mask or CwBackPixmap
+ else:
+ mask = mask or CwBackPixel
+ tray.icon = tray.display.XCreateWindow(tray.root, 0, 0, 24, 24, 0,
+ tray.depth.cuint, 1, tray.visual, mask, attrs.addr)
+ tray.gc = tray.display.XCreateGC(tray.icon, 0, nil)
+ var info: array[2, culong] = [0.culong, 1.culong]
+ tray.display.XChangeProperty(tray.icon, tray.embedInfo, tray.embedInfo, 32,
+ pmReplace, info[0].addr, 2)
+ tray.display.Xutf8SetWMProperties(tray.icon, tray.state.tooltip.cstring,
+ tray.state.tooltip.cstring, nil, 0, nil, nil, nil)
+ var event: XEvent
+ event.client = XClientMessageEvent(kind: xeClientMessage, window: tray.manager,
+ message_type: tray.opcode, format: 32)
+ event.client.data.l = [CurrentTime.clong, 0.clong, tray.icon.clong, 0.clong, 0.clong]
+ tray.display.XSendEvent(tray.manager, false, NoEventMask, event.addr)
+ tray.display.XMapWindow(tray.icon)
+
+proc channel(value: uint8, mask: culong): culong =
+ ## Scales a color channel into an arbitrary TrueColor visual mask.
+ if mask == 0: return 0
+ var shift = 0
+ while ((mask shr shift) and 1) == 0: inc shift
+ ((value.culong * (mask shr shift) + 127) div 255 shl shift) and mask
+
+proc paintIcon(tray: XembedTray) =
+ ## Scales the icon and paints premultiplied alpha using the selected visual.
+ if tray.icon == 0 or tray.width <= 0 or tray.height <= 0: return
+ let image = tray.display.XCreateImage(tray.visual, tray.depth.cuint, ZPixmap, 0,
+ nil, tray.width.cuint, tray.height.cuint, 32, 0)
+ if image == nil: raise newException(WindyError, "Unable to allocate XEmbed image")
+ defer: discard XDestroyImage(image)
+ image.data = cast[cstring](calloc(tray.height.csize_t, image.bytesPerLine.csize_t))
+ if image.data == nil: raise newException(WindyError, "Unable to allocate XEmbed pixels")
+ let
+ rgbMask = tray.visual.redMask or tray.visual.greenMask or tray.visual.blueMask
+ alphaMask = if tray.depth == 32: (0xffffffff.culong xor rgbMask) else: 0.culong
+ scale = min(tray.width.float32 / tray.state.icon.width.float32,
+ tray.height.float32 / tray.state.icon.height.float32)
+ width = max(1, int(tray.state.icon.width.float32 * scale))
+ height = max(1, int(tray.state.icon.height.float32 * scale))
+ resized = tray.state.icon.resize(width, height)
+ offsetX = (tray.width - width) div 2
+ offsetY = (tray.height - height) div 2
+ var background: ptr XImage
+ if alphaMask == 0:
+ discard tray.display.XClearArea(tray.icon, 0, 0, 0, 0, 0)
+ background = tray.display.XGetImage(tray.icon, 0, 0, tray.width.cuint,
+ tray.height.cuint, high(culong), ZPixmap)
+ defer:
+ if background != nil: discard XDestroyImage(background)
+ for y in 0..= offsetX and x < offsetX + width and y >= offsetY and y < offsetY + height:
+ c = resized[x - offsetX, y - offsetY]
+ var pixel = channel(c.r, tray.visual.redMask) or
+ channel(c.g, tray.visual.greenMask) or channel(c.b, tray.visual.blueMask)
+ if alphaMask != 0:
+ pixel = pixel or channel(c.a, alphaMask)
+ elif background != nil:
+ let bg = XGetPixel(background, x.cint, y.cint)
+ for mask in [tray.visual.redMask, tray.visual.greenMask, tray.visual.blueMask]:
+ let part = ((bg and mask) * (255 - c.a).culong div 255) and mask
+ pixel = pixel + part
+ discard XPutPixel(image, x.cint, y.cint, pixel)
+ discard tray.display.XPutImage(tray.icon, tray.gc, image, 0, 0, 0, 0,
+ tray.width.cuint, tray.height.cuint)
+
+proc ensureFonts(tray: XembedTray) =
+ ## Uses Xlib UTF-8 text with server fonts, without changing process locale.
+ if tray.fonts != nil: return
+ var
+ missing: ptr cstring
+ count: cint
+ defaultString: cstring
+ tray.fonts = tray.display.XCreateFontSet("-*-*-medium-r-normal--14-*-*-*-*-*-*-*,fixed",
+ missing.addr, count.addr, defaultString.addr)
+ if missing != nil: XFreeStringList(missing)
+ if tray.fonts == nil:
+ raise newException(WindyError, "No X11 font set is available for the tray menu")
+
+proc textWidth(tray: XembedTray, text: string): int =
+ ## Measures a UTF-8 label using the popup's font set.
+ tray.ensureFonts()
+ Xutf8TextEscapement(tray.fonts, text.cstring, text.len.cint).int
+
+proc rowHeight(entry: TrayMenuEntry): int =
+ ## Gives separators less space than selectable rows.
+ if entry.kind == TrayMenuSeparator: SeparatorHeight else: RowHeight
+
+proc rowAt(tray: XembedTray, x, y: int): int =
+ ## Returns a selectable row within the popup bounds.
+ if x < 0 or x >= tray.popupWidth: return -1
+ var top = MenuPadding
+ for index, entry in tray.state.menu:
+ if y >= top and y < top + entry.rowHeight():
+ return if entry.kind == TrayMenuOption: index else: -1
+ top += entry.rowHeight()
+ -1
+
+proc popupPixel(tray: XembedTray, rgb: culong): culong =
+ ## Packs popup colors for the default screen visual, including RGB565.
+ let visual = tray.display.screen(tray.display.defaultScreen()).rootVisual
+ channel(uint8((rgb shr 16) and 255), visual.redMask) or
+ channel(uint8((rgb shr 8) and 255), visual.greenMask) or
+ channel(uint8(rgb and 255), visual.blueMask)
+
+proc paintPopup(tray: XembedTray) =
+ ## Draws the flat menu, highlighting the selected option.
+ discard tray.display.XSetForeground(tray.popupGc, tray.popupPixel(0xf4f4f4))
+ discard tray.display.XFillRectangle(tray.popup, tray.popupGc, 0, 0,
+ tray.popupWidth.cuint, tray.popupHeight.cuint)
+ var top = MenuPadding
+ for index, entry in tray.state.menu:
+ if index == tray.hovered:
+ discard tray.display.XSetForeground(tray.popupGc, tray.popupPixel(0x3366aa))
+ discard tray.display.XFillRectangle(tray.popup, tray.popupGc, 2, top.cint,
+ (tray.popupWidth - 4).cuint, RowHeight.cuint)
+ discard tray.display.XSetForeground(tray.popupGc,
+ tray.popupPixel(if index == tray.hovered: 0xffffff else: 0x202020))
+ if entry.kind == TrayMenuSeparator:
+ discard tray.display.XDrawLine(tray.popup, tray.popupGc, MenuPadding.cint,
+ (top + 4).cint, (tray.popupWidth - MenuPadding).cint, (top + 4).cint)
+ else:
+ tray.display.Xutf8DrawString(tray.popup, tray.fonts, tray.popupGc,
+ MenuPadding.cint, (top + 18).cint, entry.text.cstring, entry.text.len.cint)
+ top += entry.rowHeight()
+
+proc floatingWindow(tray: XembedTray, x, y, width, height: int): xlib.Window =
+ ## Creates an undecorated popup constrained to the X screen.
+ let screen = tray.display.screen(tray.display.defaultScreen())
+ var attrs = XSetWindowAttributes(overrideRedirect: 1, backgroundPixel: tray.popupPixel(0xf4f4f4),
+ borderPixel: tray.popupPixel(0x555555), eventMask: ExposureMask or ButtonPressMask or
+ ButtonReleaseMask or PointerMotionMask or KeyPressMask)
+ result = tray.display.XCreateWindow(tray.root,
+ max(0, min(x, screen.size.x.int - width - 2)).cint,
+ max(0, min(y, screen.size.y.int - height - 2)).cint,
+ width.cuint, height.cuint, 1, screen.rootDepth.cuint, 1, screen.rootVisual,
+ CwBackPixel or CwBorderPixel or CwOverrideRedirect or CwEventMask, attrs.addr)
+ tray.display.XMapWindow(result)
+ tray.display.XRaiseWindow(result)
+
+proc showPopup(tray: XembedTray, x, y: int) =
+ ## Opens the menu and acquires input only for its lifetime.
+ tray.hideTooltip()
+ tray.hidePopup()
+ if tray.state.menu.len == 0: return
+ tray.popupWidth = 100
+ tray.popupHeight = MenuPadding * 2
+ for entry in tray.state.menu:
+ tray.popupHeight += entry.rowHeight()
+ if entry.kind == TrayMenuOption:
+ tray.popupWidth = max(tray.popupWidth, tray.textWidth(entry.text) + MenuPadding * 2)
+ tray.popup = tray.floatingWindow(x, y, tray.popupWidth, tray.popupHeight)
+ tray.popupGc = tray.display.XCreateGC(tray.popup, 0, nil)
+ tray.hovered = -1
+ if tray.display.XGrabPointer(tray.popup, 0, (ButtonPressMask or ButtonReleaseMask or
+ PointerMotionMask).cuint, GrabModeAsync, GrabModeAsync, 0, 0, CurrentTime) != GrabSuccess:
+ tray.hidePopup()
+ return
+ if tray.display.XGrabKeyboard(tray.popup, 0, GrabModeAsync, GrabModeAsync, CurrentTime) != GrabSuccess:
+ tray.hidePopup()
+ return
+ tray.paintPopup()
+
+proc showTooltip(tray: XembedTray) =
+ ## Displays the current tooltip after a quiet hover interval.
+ if tray.state.tooltip.len == 0: return
+ var root, child: xlib.Window
+ var rootX, rootY, windowX, windowY: cint
+ var mask: cuint
+ if tray.display.XQueryPointer(tray.root, root.addr, child.addr, rootX.addr,
+ rootY.addr, windowX.addr, windowY.addr, mask.addr) == 0: return
+ let width = tray.textWidth(tray.state.tooltip) + MenuPadding * 2
+ tray.tooltipWindow = tray.floatingWindow(rootX.int + 12, rootY.int + 20, width, RowHeight)
+
+proc paintTooltip(tray: XembedTray) =
+ ## Paints tooltip text after the window becomes visible.
+ let gc = tray.display.XCreateGC(tray.tooltipWindow, 0, nil)
+ defer: tray.display.XFreeGC(gc)
+ discard tray.display.XSetForeground(gc, tray.popupPixel(0x202020))
+ tray.display.Xutf8DrawString(tray.tooltipWindow, tray.fonts, gc, MenuPadding,
+ 18, tray.state.tooltip.cstring, tray.state.tooltip.len.cint)
+
+proc setActive*(tray: XembedTray, active: bool) =
+ ## Docks or removes this backend's icon without stopping host discovery.
+ if tray.display == nil: return
+ if (active and tray.available and tray.icon == 0) or (not active and tray.icon != 0):
+ withErrors(tray):
+ if active: tray.dock()
+ else: tray.removeIcon()
+
+proc update*(tray: XembedTray, state: TrayState) =
+ ## Replaces icon content and dismisses menus containing old callbacks.
+ tray.state = state
+ if tray.display == nil or tray.icon == 0: return
+ withErrors(tray):
+ tray.hidePopup()
+ tray.hideTooltip()
+ tray.display.Xutf8SetWMProperties(tray.icon, state.tooltip.cstring,
+ state.tooltip.cstring, nil, 0, nil, nil, nil)
+ tray.paintIcon()
+
+proc poll*(tray: XembedTray): seq[Callback] =
+ ## Reads only this connection's events; idle calls make no server round trips.
+ if tray.display == nil: return
+ let tooltipDue = tray.pointerInside and tray.popup == 0 and tray.tooltipWindow == 0 and
+ tray.state.tooltip.len > 0 and (getMonoTime() - tray.hoverStart).inMilliseconds >= TooltipDelayMs
+ if tray.display.XPending() == 0 and not tooltipDue: return
+ withErrors(tray):
+ for unused in 0..= 0:
+ let callback = tray.state.menuCallback((index + 1).int32)
+ tray.hidePopup()
+ if callback != nil: result.add callback
+ of xeMotion:
+ if window == tray.popup:
+ tray.hovered = tray.rowAt(event.motion.pos.x.int, event.motion.pos.y.int)
+ tray.paintPopup()
+ of xeKeyPress:
+ if window == tray.popup:
+ let key = XLookupKeysym(event.key.addr, 0)
+ if key == xk_Escape: tray.hidePopup()
+ elif key == xk_Return:
+ let callback = tray.state.menuCallback((tray.hovered + 1).int32)
+ tray.hidePopup()
+ if callback != nil: result.add callback
+ elif key in [xk_Up, xk_Down]:
+ let direction = if key == xk_Up: -1 else: 1
+ if tray.hovered < 0 and direction < 0: tray.hovered = 0
+ for unused in 0..".}
diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim
index 867dede..b5445f6 100644
--- a/src/windy/platforms/linux/x11.nim
+++ b/src/windy/platforms/linux/x11.nim
@@ -3,10 +3,11 @@ import
../../[common, internal],
opengl, vmath, pixie,
x11/[glx, keysym, x, xevent, xlib, xcursor],
- ./native/pixels
+ ./native/pixels, ./tray
import ../../http
export http
+export tray except pollTrayEvents
import ./backends
when HasDirect:
@@ -1619,6 +1620,7 @@ proc getClipboardImage*(): Image =
nil
proc pollEvents*() =
+ pollTrayEvents()
for window in windows:
if window.onFrame != nil:
window.onFrame()
diff --git a/src/windy/platforms/win32/platform.nim b/src/windy/platforms/win32/platform.nim
index 16d1416..ab5c801 100644
--- a/src/windy/platforms/win32/platform.nim
+++ b/src/windy/platforms/win32/platform.nim
@@ -98,17 +98,6 @@ type
style: LONG
rect: RECT
- TrayMenuEntryKind* = enum
- TrayMenuOption, TrayMenuSeparator
-
- TrayMenuEntry* = object
- case kind*: TrayMenuEntryKind
- of TrayMenuOption:
- text*: string
- onClick*: Callback
- of TrayMenuSeparator:
- discard
-
HttpRequestState = object
url, verb: string
headers: seq[HttpHeader]
diff --git a/tests/test_tray.nim b/tests/test_tray.nim
new file mode 100644
index 0000000..a6c6f46
--- /dev/null
+++ b/tests/test_tray.nim
@@ -0,0 +1,482 @@
+when defined(linux) and not defined(emscripten):
+ import
+ std/[os, osproc, monotimes, streams, times, strutils, tables],
+ pixie, windy,
+ windy/platforms/linux/tray/sni/dbus,
+ windy/platforms/linux/tray/xembed/xlib
+
+ type
+ Host = object
+ bus: Connection
+ item: string
+ registrations: int
+ attempts: int
+ replies: Table[uint32, Message]
+ available: bool
+ reject: bool
+
+ const
+ Watcher = "org.kde.StatusNotifierWatcher"
+ Item = "org.kde.StatusNotifierItem"
+ Properties = "org.freedesktop.DBus.Properties"
+ Menu = "com.canonical.dbusmenu"
+ WaitSeconds = 15
+
+ proc requestName(c: Connection, name: cstring, flags: uint32, error: pointer): cint
+ {.cdecl, dynlib: "libdbus-1.so.3", importc: "dbus_bus_request_name".}
+ ## Owns the mock watcher's well-known name on the private test bus.
+
+ proc reparent(d: Display, window, parent: xlib.Window, x, y: cint): cint
+ {.cdecl, dynlib: libX11, importc: "XReparentWindow".}
+ ## Embeds the test plug into the independent host window.
+
+ proc children(d: Display, window: xlib.Window, root, parent: ptr xlib.Window,
+ windows: ptr ptr UncheckedArray[xlib.Window], count: ptr cuint): cint
+ {.cdecl, dynlib: libX11, importc: "XQueryTree".}
+ ## Enumerates windows to find the popup without inspecting implementation state.
+
+ proc icon(): Image =
+ ## Includes transparency and unequal color channels to expose channel mistakes.
+ result = newImage(12, 8)
+ result.fill(rgba(200, 40, 80, 128))
+
+ proc app() =
+ ## Runs the real public API in another process, including callback replacement.
+ var done = false
+ var clicks = 0
+ proc clicked() =
+ ## Replaces the icon from inside its own activation callback.
+ inc clicks
+ let green = newImage(10, 10)
+ green.fill(rgba(0, 255, 0, 255))
+ showTrayIcon(green, "Updated", clicked, @[
+ TrayMenuEntry(kind: TrayMenuOption, text: "Quit_now", onClick: proc() =
+ hideTrayIcon()
+ hideTrayIcon()
+ done = true
+ )])
+ showTrayIcon(icon(), "Initial", clicked, @[
+ TrayMenuEntry(kind: TrayMenuOption, text: "Café_one", onClick: clicked),
+ TrayMenuEntry(kind: TrayMenuSeparator),
+ TrayMenuEntry(kind: TrayMenuOption, text: "Last", onClick: clicked)])
+ let deadline = getMonoTime() + initDuration(seconds = WaitSeconds)
+ while not done and getMonoTime() < deadline:
+ pollEvents()
+ sleep(1)
+ hideTrayIcon()
+ doAssert done, "The host did not finish the tray test"
+ doAssert clicks == 1
+
+ proc newHost(): Host =
+ ## Creates the watcher on an isolated session bus.
+ result.bus = busGetPrivate(0, nil)
+ doAssert result.bus != nil
+ connectionSetExitOnDisconnect(result.bus, 0)
+ doAssert requestName(result.bus, Watcher, 4, nil) == 1
+ result.available = true
+
+ proc close(host: var Host) =
+ ## Releases outstanding replies and ownership before restarting the watcher.
+ for message in host.replies.values: messageUnref(message)
+ host.replies.clear()
+ connectionClose(host.bus)
+ connectionUnref(host.bus)
+ host.bus = nil
+
+ proc pump(host: var Host) =
+ ## Answers the same watcher methods that a desktop host exposes.
+ doAssert connectionReadWrite(host.bus, 0) != 0
+ for unused in 0..<128:
+ let message = connectionPopMessage(host.bus)
+ if message == nil: break
+ let kind = messageGetType(message)
+ if kind in [2.cint, 3.cint]:
+ host.replies[messageGetReplySerial(message)] = message
+ continue
+ if kind == 1:
+ let member = $messageGetMember(message)
+ let reply = requireMessage(messageNewMethodReturn(message))
+ var args = reply.writer()
+ if member == "Get":
+ container(args, 'v', "b", value): value.addBool host.available
+ elif member == "RegisterStatusNotifierItem":
+ inc host.attempts
+ if host.reject:
+ messageUnref(reply)
+ discard host.bus.send(messageNewError(message,
+ "org.freedesktop.DBus.Error.Failed", "Rejected by test watcher"))
+ messageUnref(message)
+ continue
+ var input = message.reader()
+ doAssert input.stringValue() == "/StatusNotifierItem"
+ host.item = $messageGetSender(message)
+ inc host.registrations
+ discard host.bus.send(reply)
+ messageUnref(message)
+
+ proc hostSignal(host: var Host, member: string) =
+ ## Announces a host change through the watcher's normal signal interface.
+ discard host.bus.send(messageNewSignal("/StatusNotifierWatcher", Watcher, member.cstring))
+
+ proc settle(host: var Host) =
+ ## Gives both processes time to handle a negative discovery result.
+ let deadline = getMonoTime() + initDuration(milliseconds = 150)
+ while getMonoTime() < deadline:
+ host.pump()
+ sleep(1)
+
+ proc waitRegistration(host: var Host) =
+ ## Waits for actual client registration, with a bounded test deadline.
+ let deadline = getMonoTime() + initDuration(seconds = 5)
+ while host.item.len == 0 and getMonoTime() < deadline:
+ host.pump()
+ sleep(1)
+ doAssert host.item.len > 0, "No SNI registration received"
+
+ proc call(host: var Host, path, iface, member: string): Message =
+ ## Allocates a call addressed by the client's unique name, as real hosts do.
+ requireMessage(messageNewMethodCall(host.item.cstring, path.cstring, iface.cstring, member.cstring))
+
+ proc response(host: var Host, call: Message, expected = 2): Message =
+ ## Waits for one reply while continuing to service watcher requests.
+ let serial = host.bus.send(call)
+ let deadline = getMonoTime() + initDuration(seconds = 5)
+ while serial notin host.replies and getMonoTime() < deadline:
+ host.pump()
+ sleep(1)
+ doAssert serial in host.replies, "Missing D-Bus reply"
+ result = host.replies[serial]
+ host.replies.del(serial)
+ doAssert messageGetType(result) == expected.cint
+
+ proc property(host: var Host, iface, name: string, menu = false): Message =
+ ## Reads a property through the public D-Bus interface.
+ let call = host.call(if menu: "/Menu" else: "/StatusNotifierItem", Properties, "Get")
+ var args = call.writer()
+ args.addString iface
+ args.addString name
+ host.response(call)
+
+ proc propertyText(host: var Host, name: string, menu = false): string =
+ ## Decodes a text property from its variant.
+ let reply = host.property(if menu: Menu else: Item, name, menu)
+ defer: messageUnref(reply)
+ var args = reply.reader()
+ var value = args.recurse()
+ value.stringValue()
+
+ proc testSni() =
+ ## Verifies real bus messages, late hosts, restart, updates, and callback cleanup.
+ doAssert loadDbus()
+ let previousDisplay = getEnv("DISPLAY")
+ defer: putEnv("DISPLAY", previousDisplay)
+ putEnv("DISPLAY", "")
+ let child = startProcess(getAppFilename(), args = ["--app"], options = {poParentStreams})
+ defer: child.close()
+ sleep(100)
+ var host = newHost()
+ defer: host.close()
+ host.reject = true
+ let attemptDeadline = getMonoTime() + initDuration(seconds = 5)
+ while host.attempts == 0 and getMonoTime() < attemptDeadline:
+ host.pump()
+ sleep(1)
+ host.settle()
+ doAssert host.attempts == 1, "A failed registration was retried without a host change"
+ doAssert host.registrations == 0
+ host.reject = false
+ host.hostSignal("StatusNotifierHostRegistered")
+ host.waitRegistration()
+ doAssert host.propertyText("Title") == "Initial"
+ doAssert host.propertyText("Status", true) == "normal"
+ let all = host.call("/StatusNotifierItem", Properties, "GetAll")
+ var allArgs = all.writer()
+ allArgs.addString Item
+ let allReply = host.response(all)
+ doAssert $messageGetSignature(allReply) == "a{sv}"
+ messageUnref(allReply)
+ let imageReply = host.property(Item, "IconPixmap")
+ var args = imageReply.reader()
+ var value = args.recurse()
+ var images = value.recurse()
+ var image = images.recurse()
+ doAssert image.intValue() == 12
+ image.next()
+ doAssert image.intValue() == 8
+ image.next()
+ var bytes = image.recurse()
+ let c = icon().data[0].rgba()
+ for expected in [c.a, c.r, c.g, c.b]:
+ var actual: uint8
+ doAssert messageIterGetArgType(bytes.addr) == 'y'.cint
+ messageIterGetBasic(bytes.addr, actual.addr)
+ doAssert actual == expected
+ bytes.next()
+ messageUnref(imageReply)
+ let bad = host.call("/StatusNotifierItem", Item, "Activate")
+ messageUnref(host.response(bad, 3))
+ let introspect = host.call("/Menu", "org.freedesktop.DBus.Introspectable", "Introspect")
+ let description = host.response(introspect)
+ args = description.reader()
+ doAssert "GetLayout" in args.stringValue()
+ messageUnref(description)
+ let layoutCall = host.call("/Menu", Menu, "GetLayout")
+ var layoutArgs = layoutCall.writer()
+ layoutArgs.addInt 0
+ layoutArgs.addInt -1
+ container(layoutArgs, 'a', "s", names): discard
+ let layoutReply = host.response(layoutCall)
+ doAssert $messageGetSignature(layoutReply) == "u(ia{sv}av)"
+ messageUnref(layoutReply)
+ let labelCall = host.call("/Menu", Menu, "GetProperty")
+ var labelArgs = labelCall.writer()
+ labelArgs.addInt 1
+ labelArgs.addString "label"
+ let labelReply = host.response(labelCall)
+ args = labelReply.reader()
+ value = args.recurse()
+ doAssert value.stringValue() == "Café__one"
+ messageUnref(labelReply)
+ host.item = ""
+ host.available = false
+ host.hostSignal("StatusNotifierHostUnregistered")
+ host.settle()
+ let registrations = host.registrations
+ host.available = true
+ host.hostSignal("StatusNotifierHostRegistered")
+ host.waitRegistration()
+ doAssert host.registrations == registrations + 1
+ host.close()
+ host = newHost()
+ host.waitRegistration()
+ doAssert host.propertyText("Title") == "Initial"
+ let activate = host.call("/StatusNotifierItem", Item, "Activate")
+ var activationArgs = activate.writer()
+ activationArgs.addInt 0
+ activationArgs.addInt 0
+ messageUnref(host.response(activate))
+ doAssert host.propertyText("Title") == "Updated"
+ let stale = host.call("/Menu", Menu, "Event")
+ var staleArgs = stale.writer()
+ staleArgs.addInt 1
+ staleArgs.addString "clicked"
+ container(staleArgs, 'v', "i", data): data.addInt 0
+ staleArgs.addUint 0
+ messageUnref(host.response(stale))
+ doAssert child.running(), "A stale menu event invoked a replacement action"
+ let quit = host.call("/Menu", Menu, "Event")
+ var quitArgs = quit.writer()
+ quitArgs.addInt 4
+ quitArgs.addString "clicked"
+ container(quitArgs, 'v', "i", data): data.addInt 0
+ quitArgs.addUint 0
+ messageUnref(host.response(quit))
+ doAssert child.waitForExit(5000) == 0
+
+ proc sendButton(d: Display, window: xlib.Window, button: cuint, release = true,
+ x = 10, y = 10) =
+ ## Sends input through the server to the client's selected event mask.
+ var event: XEvent
+ event.button = XButtonEvent(kind: if release: xeButtonRelease else: xeButtonPress,
+ display: d, window: window, root: d.defaultRootWindow(),
+ pos: ivec2(x.int32, y.int32), rootPos: ivec2(40, 40), button: button, same_screen: 1)
+ d.XSendEvent(window, false, if release: ButtonReleaseMask else: ButtonPressMask, event.addr)
+ d.XFlush()
+
+ proc sendKey(d: Display, window: xlib.Window, keysym: KeySym) =
+ ## Sends a keyboard command to the popup through the independent connection.
+ proc keycode(d: Display, symbol: KeySym): uint8
+ {.cdecl, importc: "XKeysymToKeycode", dynlib: libX11.}
+ var event: XEvent
+ event.key = XKeyEvent(kind: xeKeyPress, display: d, window: window,
+ root: d.defaultRootWindow(), keycode: keycode(d, keysym).cuint, same_screen: 1)
+ d.XSendEvent(window, false, KeyPressMask, event.addr)
+ d.XFlush()
+
+ proc testXembed(withSni = false) =
+ ## Acts as a tray manager on Xvfb, validating windows, pixels, menus, and restart.
+ let previousBus = getEnv("DBUS_SESSION_BUS_ADDRESS")
+ defer: putEnv("DBUS_SESSION_BUS_ADDRESS", previousBus)
+ if not withSni:
+ putEnv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent-windy-tray-test")
+ let d = XOpenDisplay(nil)
+ doAssert d != nil
+ defer: discard d.XCloseDisplay()
+ let root = d.defaultRootWindow()
+ let selection = d.XInternAtom(("_NET_SYSTEM_TRAY_S" & $d.defaultScreen()).cstring, 0)
+ let managerAtom = d.XInternAtom("MANAGER", 0)
+ let opcode = d.XInternAtom("_NET_SYSTEM_TRAY_OPCODE", 0)
+ var manager: xlib.Window
+ var plug: xlib.Window
+ proc createManager(useAlpha = false) =
+ ## Publishes a manager and announces its appearance after the client starts.
+ manager = d.XCreateSimpleWindow(root, 0, 0, 100, 60, 0, 0, 0xffffff)
+ if useAlpha:
+ var info: XVisualInfo
+ d.XMatchVisualInfo(d.defaultScreen(), 32, 4, info.addr)
+ doAssert info.visual != nil, "Xvfb must expose an ARGB visual"
+ d.XChangeProperty(manager, d.XInternAtom("_NET_SYSTEM_TRAY_VISUAL", 0),
+ d.XInternAtom("VISUALID", 0), 32, pmReplace, info.visualid.addr, 1)
+ d.XSetSelectionOwner(selection, manager)
+ d.XMapWindow(manager)
+ var event: XEvent
+ event.client = XClientMessageEvent(kind: xeClientMessage, window: root,
+ message_type: managerAtom, format: 32)
+ event.client.data.l[1] = selection.clong
+ event.client.data.l[2] = manager.clong
+ d.XSendEvent(root, false, StructureNotifyMask, event.addr)
+ d.XSync()
+ proc waitDock() =
+ ## Handles a real docking request and embeds the advertised plug.
+ let deadline = getMonoTime() + initDuration(seconds = 5)
+ plug = 0
+ while plug == 0 and getMonoTime() < deadline:
+ while d.XPending() > 0:
+ var event: XEvent
+ d.XNextEvent(event.addr)
+ if event.kind == xeClientMessage and event.client.message_type == opcode:
+ doAssert event.client.data.l[1] == 0
+ plug = event.client.data.l[2].culong
+ discard d.reparent(plug, manager, 0, 0)
+ d.XResizeWindow(plug, 32, 24)
+ d.XMapWindow(plug)
+ var embedded: XEvent
+ embedded.client = XClientMessageEvent(kind: xeClientMessage, window: plug,
+ message_type: d.XInternAtom("_XEMBED", 0), format: 32)
+ embedded.client.data.l[3] = manager.clong
+ d.XSendEvent(plug, false, NoEventMask, embedded.addr)
+ d.XSync()
+ sleep(1)
+ doAssert plug != 0, "No XEmbed docking request received"
+ proc pixel(): culong =
+ ## Reads the rendered center pixel without inspecting backend state.
+ let image = d.XGetImage(plug, 16, 12, 1, 1, high(culong), 2)
+ doAssert image != nil
+ result = XGetPixel(image, 0, 0)
+ discard XDestroyImage(image)
+ var host: Host
+ if withSni:
+ host = newHost()
+ createManager()
+ let child = startProcess(getAppFilename(), args = ["--app"], options = {poParentStreams})
+ defer: child.close()
+ sleep(100)
+ if withSni:
+ host.waitRegistration()
+ doAssert host.propertyText("Title") == "Initial"
+ while d.XPending() > 0:
+ var event: XEvent
+ d.XNextEvent(event.addr)
+ doAssert event.kind != xeClientMessage or event.client.message_type != opcode,
+ "Both tray backends registered an icon"
+ host.close()
+ else:
+ createManager()
+ waitDock()
+ let firstDeadline = getMonoTime() + initDuration(seconds = 5)
+ while pixel() != 0xe393a7 and getMonoTime() < firstDeadline: sleep(1)
+ doAssert pixel() == 0xe393a7, "Transparent icon did not blend with the tray background"
+ d.XDestroyWindow(manager)
+ d.XSync()
+ sleep(100)
+ createManager(true)
+ waitDock()
+ let alphaDeadline = getMonoTime() + initDuration(seconds = 5)
+ while pixel() != 0x80641428.culong and getMonoTime() < alphaDeadline: sleep(1)
+ doAssert pixel() == 0x80641428.culong, "ARGB visual did not preserve premultiplied alpha"
+ sendButton(d, plug, 1)
+ let deadline = getMonoTime() + initDuration(seconds = 5)
+ while (pixel() and 0xffffff) != 0x00ff00 and getMonoTime() < deadline: sleep(1)
+ doAssert (pixel() and 0xffffff) == 0x00ff00, "Updated icon was not painted"
+ sendButton(d, plug, 3)
+ var popup: xlib.Window
+ while popup == 0 and getMonoTime() < deadline:
+ var rootOut, parent: xlib.Window
+ var windows: ptr UncheckedArray[xlib.Window]
+ var count: cuint
+ discard d.children(root, rootOut.addr, parent.addr, windows.addr, count.addr)
+ for index in 0.. 0 and displayNumber.allCharsInSet({'0'..'9'})
+ putEnv("DISPLAY", ":" & displayNumber)
+ testPending()
+ testSni()
+ when HasX11:
+ testXembed()
+ testXembed(true)
+
+ if paramCount() > 0 and paramStr(1) == "--app":
+ app()
+ elif paramCount() > 0 and paramStr(1) == "--isolated":
+ suite()
+ else:
+ # Never acquire tray ownership or create windows on the user's session.
+ let isolated = startProcess("dbus-run-session", args = ["--", getAppFilename(),
+ "--isolated"], options = {poUsePath, poParentStreams})
+ defer: isolated.close()
+ doAssert isolated.waitForExit(90000) == 0