System tray (StatusNotifierItem) for MoonBit on Linux desktops — pure MoonBit, built on moondbus.
Dependencies
| Package | What it gives you | Depends on |
|---|---|---|
| moonsni/src/menu | Pure menu data model — Menu, ItemHandle, SubMenu, checkboxes, i18n. No D-Bus at all. | nothing |
| moonsni/src/dbusmenu | Pure com.canonical.dbusmenu wire codec — encode a menu tree, parse events. | menu + moondbus encoder |
| moonsni/src/tray | The full tray — connect to the bus, register the item, serve requests. | menu + dbusmenu + moondbus |
moon add conglinyizhi/moonsniimport {
"conglinyizhi/moonsni/src/tray" @tray,
}
supported_targets = "+native"import {
"conglinyizhi/moonsni/src/menu" @menu,
}///|
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)
}///|
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")
}
}///|
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) { })
}///|
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")
}
}///|
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
})
}///|
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")
}
}///|
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)
}///|
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
}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)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.
| Method | Behaviour |
|---|---|
| GetLayout | Returns the menu tree as (u(ia{sv}av)). Honours the parentID argument so the desktop can fetch a submenu's content independently. |
| Event | clicked events are routed to the handler registered for that id |
| AboutToShow | Returns false (no dynamic rebuild needed) |
| Function | Purpose |
|---|---|
| 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 |
| Function | Purpose |
|---|---|
| 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/submenu | Nest further |
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" | ...
}# 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.StatusNotifierItemmoon build --target native
./_build/native/debug/build/conglinyizhi/moonsni/cmd/tray-demo/tray-demo.exeInstall
Download zipSystem tray (StatusNotifierItem) for MoonBit on Linux desktops — pure MoonBit, built on moondbus.
Dependencies