moonsni

System tray (StatusNotifierItem) for MoonBit on Linux desktops — pure MoonBit, built on moondbus.

dbus
tray
statusnotifieritem
kde
linux
desktop
Download zip
Version
0.1.0
License
Apache-2.0
Last updated
22 hours ago
Downloads
2

Dependencies

#moonsni

System tray icons for MoonBit on Linux — a pure MoonBit implementation of the freedesktop StatusNotifierItem (SNI) specification, built on moondbus.

No libappindicator, no Qt, no GTK. The D-Bus protocol and the SNI service are both written in MoonBit.

The code blocks marked mbt check below are actually compiled and run by moon check / moon test, so the documented API stays true to the code. A full runnable tray is in cmd/tray-demo; extra compiled snippets live in examples/doc.

#Progressive by design

moonsni is not a framework you have to adopt wholesale. It is built as three independent packages, so you take only what you need:

PackageWhat it gives youDepends on
moonsni/src/menuPure menu data modelMenu, ItemHandle, SubMenu, checkboxes, i18n. No D-Bus at all.nothing
moonsni/src/dbusmenuPure com.canonical.dbusmenu wire codec — encode a menu tree, parse events.menu + moondbus encoder
moonsni/src/trayThe full tray — connect to the bus, register the item, serve requests.menu + dbusmenu + moondbus

This mirrors Vue's spectrum: use the whole thing for a quick tray, or drop down a level and use just the menu model, or swap in your own wire layer entirely.

  • Just want a tray? @tray.tray(cfg) + .on(...) + .run(). One line each.
  • Only need a menu, no D-Bus? Use @menu.Menu on its own — it is pure data.
  • Serving your own menu, want to reuse the codec? Call @dbusmenu.encode_layout.
  • Want a different transport? Write your own package that feeds menu and drives dbusmenutray is simply one such wiring.

#Install

moon add conglinyizhi/moonsni

For the full tray:

import { "conglinyizhi/moonsni/src/tray" @tray, } supported_targets = "+native"

For just the menu model (no D-Bus dependency pulled in):

import { "conglinyizhi/moonsni/src/menu" @menu, }

#Quick start

Menu items are registered like routes — a key, a title, and a handler. You get a handle back that lets you change that item at any time.

///|
test "quick start — build a tray and register menu items" {
let cfg = @tray.default_tray_config(
"my-app", // Id
"My Application", // Title
"applications-system", // IconName
)
let t = @tray.tray(cfg)

let count = Array::make(1, 0)
let hello = t.on("hello", "Hello", fn(item) {
count[0] = count[0] + 1
item.set_title("Clicked " + count[0].to_string())
})
let mut _quit = t.on("quit", "Quit", fn(_item) { })

// 句柄可随时改标题/可用/可见
hello.set_title("New title")
hello.set_enabled(false)
hello.set_visible(true)
}

The handle is available in two places — as the return value of on(...) and as the argument passed to the handler:

///|
test "handle is live and updatable" {
let t = @tray.tray(
@tray.default_tray_config("app", "App", "applications-system"),
)
let hello = t.on("hello", "Hello", fn(item) { item.set_title("Clicked") })
hello.set_title("New title") // from anywhere
hello.set_enabled(false)
hello.set_visible(true)

match t.item("hello") {
Some(it) => assert_eq(it.title(), "New title")
None => fail("hello item not found")
}
}

Beyond plain actions, the menu supports separators, checkboxes, and submenus of any depth:

///|
test "menu building blocks" {
let t = @tray.tray(
@tray.default_tray_config("app", "App", "applications-system"),
)

t.on("hello", "Hello", fn(_item) { }) |> ignore
t.separator() // divider

t.checkbox("dark", "Dark mode", false, fn(item) { // checkbox
// checked is already toggled; item.checked() reflects it
let _toggled = item.checked()
})
|> ignore

let tools = t.submenu("tools", "Tools") // submenu -> builder
let _tool0 = tools.on("tool-0", "Tool #0", fn(_item) { })
tools.separator()

let advanced = tools.submenu("advanced", "Advanced") // nested submenu
let _adv0 = advanced.on("adv-0", "Advanced #0", fn(_item) { })

let deeper = advanced.submenu("deeper", "Even deeper") // grandchild
let _bottom = deeper.on("bottom", "You found the bottom", fn(_item) { })
}

  • .separator() renders a divider
  • .checkbox(key, title, checked, handler) auto-toggles checked on click
  • .submenu(key, title) returns a builder with the same on/checkbox/separator/submenu methods, so nesting is unlimited
  • The submenu builder itself has .handle() to get a handle for the container

Every handle has set_title, set_enabled, set_visible, set_checked, toggle, title, checked, key.

#Internationalization (i18n)

Menus are internationalization-friendly by design. Register items with a description key instead of a literal label, then apply a translation table at runtime — without rebuilding the menu:

///|
test "runtime i18n" {
let t = @tray.tray(
@tray.default_tray_config("app", "App", "applications-system"),
)
t.on("menu.file", "File", fn(_item) { }) |> ignore
t.on("menu.new", "New document", fn(_item) { }) |> ignore

// 初始英文
let en : Map[String, String] = {
"menu.file": "File",
"menu.new": "New document",
}
t.apply_translations(en)
match t.item("menu.file") {
Some(it) => assert_eq(it.title(), "File")
None => fail("not found")
}

// 运行时切中文
let zh : Map[String, String] = {
"menu.file": "文件",
"menu.new": "新建文档",
}
t.apply_translations(zh)
match t.item("menu.file") {
Some(it) => assert_eq(it.title(), "文件")
None => fail("not found")
}
}

apply_translations walks the whole menu tree and replaces each item's title by its key. Every item's key is preserved, so any mapped label can be swapped. Unmapped keys keep their current title.

This fits the gettext pattern: use menu.hello-style keys in code, resolve labels from a catalog. Verify with the tray-demo example — a Language submenu switches the entire menu between Chinese and English at runtime.

When a handler changes an item, the menu is marked dirty and a com.canonical.dbusmenu.LayoutUpdated signal is emitted, so the desktop re-reads the layout.

#Handlers must return quickly

run() serves requests in a single blocking loop, and handlers run inside that loop. A slow handler blocks everything else — keep handlers short and push real work elsewhere.

#Left / middle / scroll events

///|
test "pointer & scroll events" {
let t = @tray.tray(
@tray.default_tray_config("app", "App", "applications-system"),
)

t.on_click(fn(x, y) { let _ = (x, y) }) // left-click Activate
t.on_middle_click(fn(x, y) { let _ = (x, y) }) // middle-click SecondaryActivate
t.on_scroll(fn(delta, orientation) { // wheel Scroll
let _ = (delta, orientation) // orientation 0=vertical 1=horizontal
})
}

#Use only the menu model (no D-Bus)

@menu is pure data — build a menu without any tray or D-Bus:

///|
test "pure menu model, no D-Bus" {
let m = @menu.Menu::new()
let h = m.on("qr", "识别二维码", fn(item) {
item.set_title("识别中…")
})
h.set_title("扫描中…")

match m.get("qr") {
Some(it) => assert_eq(it.title(), "扫描中…")
None => fail("not found")
}
}

#Use the dbusmenu codec on your own data

@dbusmenu is a pure codec — feed it any @menu.Menu and get a layout:

///|
test "dbusmenu codec is pure" {
let m = @menu.Menu::new()
m.on("a", "A", fn(_item) { }) |> ignore
let bytes = @dbusmenu.encode_layout(m, 1, 0, -1) // whole tree
assert_true(bytes.length() > 0)
}

#Full runnable tray

A complete, runnable fn main tray (this one genuinely starts a tray icon on the desktop — it's not compiled as a test because run() blocks forever):

///|
fn main {
let cfg = @tray.default_tray_config(
"my-app", "My Application", "applications-system",
)
let t = @tray.tray(cfg)
t.on("hello", "Hello", fn(item) { item.set_title("Clicked") }) |> ignore
t.run() // blocks; serves the tray until killed
}

#How it works

run_tray() ├─ connect to the session bus + SASL auth (moondbus Server) ├─ Hello handshake ├─ RequestName org.kde.StatusNotifierItem-<pid>-1 ├─ RegisterStatusNotifierItem → status notifier watcher └─ serve loop (moondbus Server::serve) ├─ org.freedesktop.DBus.Properties.Get → single property ├─ org.freedesktop.DBus.Properties.GetAll → a{sv} of all properties ├─ com.canonical.dbusmenu.GetLayout/Event/AboutToShow → menu ├─ org.kde.StatusNotifierItem.Activate → on_click(x, y) ├─ org.kde.StatusNotifierItem.SecondaryActivate → on_middle_click(x, y) └─ org.kde.StatusNotifierItem.Scroll → on_scroll(delta, dir)

The serve loop is provided by moondbus's Server (a reusable D-Bus service loop), so any future service — MPRIS player, custom object export — can reuse it instead of hand-writing recv / parse / reply.

Properties served: Category, Id, Title, Status, IconName, Menu (object path), ItemIsMenu.

Note: Menu must be present in the GetAll reply, otherwise the desktop does not know the item has a menu and right-clicking does nothing. ItemIsMenu should be true so the desktop renders a single flat menu rather than treating the item as an activation target.

Menu interface (com.canonical.dbusmenu at /MenuBar):

MethodBehaviour
GetLayoutReturns the menu tree as (u(ia{sv}av)). Honours the parentID argument so the desktop can fetch a submenu's content independently.
Eventclicked events are routed to the handler registered for that id
AboutToShowReturns false (no dynamic rebuild needed)

#Protocol detail notes

Two things cost us debugging time and are worth documenting:

  1. toggle-type / toggle-state must be present on every item (an empty string / 0 for plain actions and submenus). KDE uses them to distinguish item types; without them a submenu can be rendered as the whole parent menu.
  2. GetLayout receives a parentID (0 for the root, a submenu's id when the desktop wants just that submenu). Reply with the corresponding subtree root, not the whole menu every time — otherwise clicking a submenu shows the entire parent menu again.

#API

#Tray package (@tray)

FunctionPurpose
tray(cfg)Create a tray
Tray::on(key, title, handler)Register a menu item, returns a handle
Tray::item(key)Look up a handle by key
Tray::run()Register with the desktop and serve (blocks)
Tray::separator()Add a divider
Tray::checkbox(key, title, checked, h)Add a checkbox
Tray::submenu(key, title)Add a submenu (returns a builder)
Tray::apply_translations(table)Switch language
Tray::on_click(fn(x, y))Left-click activation (Activate)
Tray::on_middle_click(fn(x, y))Middle-click (SecondaryActivate)
Tray::on_scroll(fn(delta, dir))Mouse wheel (Scroll), dir 0=vertical 1=horizontal
run_tray(cfg)Shortcut for a tray with no menu

FunctionPurpose
Menu::new()Create an empty menu
Menu::on(key, title, handler)Add an action, returns a handle
Menu::checkbox(key, title, checked, h)Add a checkbox
Menu::submenu(key, title)Add a submenu
Menu::separator()Add a divider
Menu::get(key)Look up a handle
Menu::dispatch(id)Route a click by id
Menu::apply_translations(table)Switch language
SubMenu::on/checkbox/separator/submenuNest further

#Handles

Every handle has set_title, set_enabled, set_visible, set_checked, toggle, title, checked, key.

#Configuration

pub struct TrayConfig { id : String // application id, e.g. "my-app" title : String // human readable title icon_name : String // freedesktop icon theme name status : String // "Active" | "Passive" | "NeedsAttention" category : String // "ApplicationStatus" | "Communications" | ... }

default_tray_config(id, title, icon_name) fills status = "Active" and category = "ApplicationStatus".

Pick icon_name from your icon theme — applications-system, dialog-information, media-record, etc. Run ls /usr/share/icons/breeze/ (or your theme) to browse.

#Verify it works

With the demo running:

# should list org.kde.StatusNotifierItem-<pid>-1 busctl --user list | grep StatusNotifierItem # should return your properties busctl --user call org.kde.StatusNotifierItem-<pid>-1 \ /StatusNotifierItem org.freedesktop.DBus.Properties \ GetAll s org.kde.StatusNotifierItem

#Example

cmd/tray-demo is a minimal runnable tray:

moon build --target native ./_build/native/debug/build/conglinyizhi/moonsni/cmd/tray-demo/tray-demo.exe

#Platform & testing scope

Tested only on the author's local machine — KDE Plasma 6 (Wayland) on the same Arch-based Linux system the library was developed on. This is a single-environment verification:

  • Only KDE Plasma 6 / Wayland has been exercised (right-click menu, pointer / scroll events, i18n switching, NewIcon / LayoutUpdated signals).
  • Not verified on other desktops: XFCE, Cinnamon, LXQt, GNOME + extension, or any other StatusNotifierItem host.
  • Not verified on other platforms: only Linux / native backend is built; no Windows or macOS testing has been done (D-Bus has no unix-socket transport on Windows, see the roadmap).
  • Not verified on a different mooncakes / toolchain set beyond the nightly toolchain used for development.

If you run this on a different desktop or distribution and hit a problem, it would be valuable to report it — the behavior may just not have been tested here yet.

#Status & limitations

Works today:

  • Registering a tray item and keeping it alive
  • Serving Get / GetAll for all properties above
  • Right-click context menu with click routing and live title updates
  • Left / middle / scroll events (Activate, SecondaryActivate, Scroll)
  • NewIcon / LayoutUpdated signals
  • Submenus of arbitrary depth
  • Runtime i18n switching (verified: whole menu switches language live)
  • Verified on KDE Plasma 6 (Wayland)

Experimental (API ready, wire codec not yet verified):

  • PixelIcon / set_icon / set_icons / set_icon_unchecked / clear_pixmap — dynamic tray icon via IconPixmap (a(iiibay)). The API and the input validation (size / ARGB length / over-large guard) are in place and unit tested, but the D-Bus encoding is not yet verified against KDE (serving the property can make the desktop drop the connection). Stick to IconName for now.

Not implemented yet:

  • Radio groups / toggle-only-radio behavior
  • ToolTip, AttentionIconName
  • Removing menu items after registration

#Roadmap

  • Windows: D-Bus has no unix-socket transport on Windows — the reference dbus-daemon uses TCP loopback (autolaunch: / tcp:host=localhost,port=NNNN). The menu and dbusmenu packages are already independent of any transport, so a future Windows backend only needs to plug in a D-Bus transport layer; the tray logic and menu codec are reused as-is. No Windows hardware is available to test this yet.

#License

Apache-2.0