muri
Menu Utilities for Rust Interfaces — a cross-platform, fully-styleable
tray-icon + popup-menu system for Rust. Think "a better muda + tray-icon":
muri owns the tray icon, the styled popup, and the anchoring, and draws one
consistent custom appearance on every OS instead of delegating to native
menus.
Install
muri is published on crates.io. It is pre-1.0 (0.10.x), so the public API
may still change between releases while the three backends are device-verified and
the surface stabilizes — pin at least the minor version:
[]
= "0.10"
# with accessibility: muri = { version = "0.10", features = ["a11y"] }
# migrating from muda/tray-icon: muri = { version = "0.10", features = ["muda-compat"] }
# bundled OSS UI fonts for forced cross-platform themes:
# muri = { version = "0.10", features = ["bundled-fonts"] }
# tray-only Linux, no styled X11 popup (drops the x11rb stack):
# muri = { version = "0.10", default-features = false }
# or track the latest unreleased work from git:
# muri = { git = "https://github.com/MattJackson/muri" }
Status: all three backends implemented; on-device verification ongoing. muri draws its own styled popup on macOS and Windows, and installs a native menu on Linux.
Tray::runinstalls the OS status item, opens the menu, opens flyout submenu panels, follows dark/light, and supports keyboard navigation (arrows / Home-End / Right-Left / Enter-Space / Esc / type-ahead) over the same hover-stack the mouse drives. muri also publishes a parallel accessibility tree (Tray::accessibility_tree) that maps the menu onto menu/menuitem roles with name, checked, enabled, submenu-expanded, and set-position, and (behind thea11yfeature) attaches an AccessKit platform adapter to the popup window so that tree is exposed to NSAccessibility / VoiceOver (macOS) and UIA / NVDA / Narrator (Windows). The Linux tray is a nativecom.canonical.dbusmenumenu (host-rendered — see below), with a pointer-anchored styledContextMenuavailable on X11.Every backend compiles + is clippy-clean on its target in CI, and the pure cross-platform core is unit-tested green on every OS; what remains is exercising each backend, and the screen readers, on real hardware. See the platform matrix for exactly what is verified vs. pending.
Why muri exists
Native menus (NSMenu, Win32 HMENU, GTK MenuItem) draw their own pixels, so
you cannot restyle them: no custom fonts/colors, no true multi-column alignment,
and always a reserved chevron/submenu column that prevents values from sitting
flush at the right edge. muda is a thin data-model sync over those native
objects — there is no drawable layer to fork.
muri instead draws the menu itself, on a CPU raster surface, identically on every platform. That single owned surface is what makes these possible:
- True left / center / right alignment and multi-column
label … valuerows. - Flush right-aligned values with no reserved chevron column.
- Arbitrary colors, fonts, and embedded logos (PNG or a restricted SVG subset — paths/shapes/solid fills, rasterized by muri's own zeno-backed layer).
- Styled nested submenus (flyout panels beside the row).
- One consistent look that still follows OS dark/light + accent by default, and is fully overridable via a theme API.
Design decisions (fixed)
- Name: muri. License: MIT.
- One consistent custom-drawn appearance across all OSes — not native-per-OS chrome.
- Rendering stack: text is shaped and rasterized with
fontdb(font discovery) +harfrust(HarfBuzz-project shaper) +swash(glyph rendering); the restrictedIcon::Svgsubset is filled withzeno; everything is composited by muri's own in-house CPUFramebufferblitter, withpngthe only image codec. No general-purpose 2D crate, no GPU. Windowing is native per-OS (see below). Minimal deps, tiny binary, no GPU warm-up, instant popups, full pixel control. - muri owns the whole stack: the tray icon, the styled popup, and the
anchoring (a full
muda+tray-iconreplacement). - Accessibility is first-class: native a11y APIs are wired over the
custom-drawn widget tree via AccessKit — NSAccessibility (macOS), UIA (Windows),
AT-SPI (Linux, via the native menu) — behind the
a11yfeature, targeted to be on by default at 1.0. - v1 OS order: macOS → Windows → Linux (all three now implemented).
Feature flags
muri's default surface is default = ["x11-popup"]; the other integrations sit
behind opt-in flags. All ship today.
| Flag | Default | Description |
|---|---|---|
x11-popup |
on | Linux only: the styled, pointer-anchored ContextMenu::open_at popup, via the pure-Rust x11rb X11 client. On by default for back-compat and muda-compat parity; a tray-only consumer can default-features = false to drop x11rb + x11rb-protocol + gethostname and keep just the SNI tray. Inert on non-Linux targets. |
a11y |
off | AccessKit screen-reader bridge over the raster backend (accesskit_macos → NSAccessibility, accesskit_windows → UIA; Linux via the native menu / AT-SPI). Ships today; planned to be on by default at 1.0. |
muda-compat |
off | A muda / tray-icon drop-in compatibility facade (compat::muda / compat::tray_icon) so existing callers can migrate with minimal churn. Maps entirely onto muri's native model — it does not pull in the real muda / tray-icon crates (see Migrating from muda). |
bundled-fonts |
off | Embeds freely-redistributable OSS UI-font substitutes (OFL Inter for SF Pro, Microsoft's own OFL Selawik for Segoe UI, genuine Cantarell for GNOME) so a forced cross-platform theme (ThemeSource::MacOs/Windows/Gnome) can render in a metric-compatible face when the real OEM font isn't installed on the host — never silently drawing the wrong-OS host font. OFF by default to keep the crate small; the TTFs are include_bytes!-embedded only when on. See assets/fonts/README.md. |
Quick example
use ;
let menu = new
.section_header
.row
.separator
.row;
let tray = new
.tooltip
.menu
.on_click;
// let m = muri::MainThreadMarker::new().unwrap(); // call on the real main thread
// tray.run(m)?; // installs the tray icon + runs the event loop (styled popup on macOS/Windows; native menu on Linux)
See examples/usagio_menu.rs for usagio's full real
menu — provider groups, flush-right colored percentages, and account submenus —
rebuilt through the API.
Migrating from muda
Why
muda + tray-icon
sync a data model onto native OS menu objects (NSMenu, Win32 HMENU, GTK
menus), so you inherit the OS look and cannot restyle — no custom
fonts/colors, no true multi-column alignment, and always a reserved
chevron/submenu column that stops values sitting flush at the right edge. muri
draws the menu itself on a CPU raster surface, identically on every OS, so you
get full styling (flush-right values, colors, fonts, embedded logos, nested
flyouts) while still following the OS dark/light + accent by default.
The migration is designed so s/muda/muri/ compiles, runs, and looks native
immediately — then lets you restyle at your own pace, with no cliff.
How
- Swap the dependency — replace
muda+tray-iconwith:[] = { = "0.10", = ["muda-compat"] } - Redirect imports — your menu-building code compiles unchanged:
use muda as muda; use tray_icon as tray_icon;Menu,Submenu,MenuItem,CheckMenuItem,PredefinedMenuItem, andMenuIdare all mirrored;MenuIdis structurally identical, soevent.id.0still matches your existing handlers. - Keep your event reader —
MenuEvent::receiver()works as-is; muri fires the same process-global channel (closure first, then the channel). - The one real code change — the event loop. muda/tray-icon are passive
(they push to a global channel while your loop runs); muri's
Trayowns a loop (Tray::run(self), main thread on macOS). Either let muri own it (tray.run()blocks; readMenuEvent::receiver()off-thread) or drive it from your own loop. For runtime menu updates, grab aTrayHandlebeforerun()and callset_menu/set_icon/set_tooltipfrom any thread. - Routing —
Menu::init_for_nsapp/hwnd/gtk_windowpasses through to the native menu bar;TrayIconBuilder…with_menuandshow_context_menu_for_*render as muri's custom surface withTheme::native(). - Then restyle progressively — Step 1: swap the theme. Step 2: make a row a
Flex::Growlabel +Align::Rightcolored value (flush-right, no chevron column) with aStyleRunspan. Step 3: add section headers, logos, and submenus. Ids never change, so your handlers keep matching.
Honest caveats
In custom surfaces (tray/context menus), PredefinedMenuItem OS actions are
best-effort and inemulable ones render as visible disabled rows (never
silently dropped); accelerators are displayed and handled only while the menu
is open (no system-wide hotkey — use global-hotkey for that); Linux has no
styled tray-anchored popup (native-menu fallback + pointer-anchored
ContextMenu); and the facade maps entirely onto muri — it does not pull in
the real muda / tray-icon crates. See the migration guide in
docs/design/spec/60-migration-guide.md
for the full divergence register.
Platform support (honest matrix)
Legend: ✅ working (automated-tested / live) · 🔬 implemented, on-device verification pending · ❌ not offered (by design).
Note the key nuance: muri draws its own styled menu on macOS and Windows; the
Linux tray menu is a native com.canonical.dbusmenu — the SNI/AppIndicator
host renders it, so it does not follow muri's theme or fonts. muri's own
styled popup on Linux is the pointer-anchored ContextMenu (X11 only today;
Wayland styled positioning returns Unsupported).
| OS | Tray icon | Styled anchored popup | Context menu (open_at) |
Screen reader |
|---|---|---|---|---|
| macOS | 🔬 NSStatusItem |
🔬 non-activating NSPanel + vibrancy, N-level flyouts, mouse + keyboard nav |
🔬 open_at + Popup (shared PopupSession) |
🔬 VoiceOver (per-window AccessKit adapters wired) |
| Windows | 🔬 Shell_NotifyIcon |
🔬 WS_EX_NOACTIVATE layered popup, DWM acrylic, WH_MOUSE_LL dismiss |
🔬 open_at + Popup (reuses the layered popup) |
🔬 NVDA + Narrator (UIA via accesskit_windows) |
| Linux | 🔬 SNI/AppIndicator native dbusmenu (host-rendered; not muri-themed) |
❌ tray-anchored (by design — see below); use pointer ContextMenu |
🔬 X11 override-redirect open_at (x11-popup feature; Wayland: Unsupported::ClientPositioning) |
🔬 Orca (AT-SPI via the native menu) |
All-green yet? No — by design. The pure cross-platform core (menu model,
Flex/Align layout, flyout/anchor math, keyboard-nav state machine, theme
resolution, a11y tree) is unit-tested green on every OS, and every backend
compiles + is clippy-clean on its target in CI. The 🔬 cells are implemented
muri code that builds but hasn't been exercised on real hardware yet — verifying
them, and the four screen readers, is the remaining pre-1.0 work; each flips to ✅
as it's confirmed on the road to 1.0 (the all-four-screen-readers pass is a hard
1.0 gate). The Linux tray-anchored styled popup stays ❌ permanently.
The Linux caveat (read this)
A tray-anchored styled popup is architecturally impossible on Linux/Wayland, and muri will not pretend otherwise:
- The modern Linux tray is StatusNotifierItem / AppIndicator over D-Bus. The
host (a GNOME extension, KDE plasmoid, or XEmbed shim) owns and draws the
icon in its own process and renders the menu from a
com.canonical.dbusmenudescription. The app is never told the icon's on-screen rectangle and never receives the click coordinate (tray-icondocuments both as Unsupported on Linux). - Wayland forbids a client from positioning its own toplevel by protocol —
set_outer_positionis a documented no-op.
So on Linux muri offers a fallback instead of a broken promise:
- Native-menu fallback — render the same
Menuspec through a nativecom.canonical.dbusmenutree (viaksni, the SNI/AppIndicator host renders it, so it loses custom styling but works everywhere a Linux tray works). This is the recommended default. - Pointer-anchored
ContextMenu— the styled surface is available where a pointer coordinate exists (a right-click menu), just not anchored to the tray icon.
Tray::run returns Err(Error::Unsupported(Unsupported::TrayAnchor)) on Linux
so callers fall back deliberately.
Theming
A ThemeSource chooses the look; it resolves to a concrete Theme (colors,
fonts, spacing, corner radius, row height, column gap) that you can fully
override:
ThemeSource::System(ThemeMode)— the default (System(ThemeMode::Auto)). Matches the host OS look and is the only source that receives live OS injection: the real accent, menu colors (NSColor/GetSysColor/ Adwaita), and the system UI font + point size.ThemeSource::MacOs/Windows/Gnome(each(ThemeMode)) — force a specific platform look on any host (a macOS app can render a Windows menu), drawn as-authored with that OS's native metrics (macOS ~22pt rows / 6pt radius- SF tracking, Win11 acrylic / 8pt radius / Segoe UI, Adwaita flat / 12pt radius / Cantarell).
ThemeSource::Preset(Preset)— a built-in non-OS skin, rendered as-authored on every platform:OldSchoolTerminal,HighContrast,Solarized,Nord.ThemeSource::Custom(Box<Theme>)— a fully hand-builtTheme.
ThemeMode is Auto (follow the OS light/dark setting — the default), Light,
or Dark. Colors on a row are either literal Color::Rgba(..) or semantic
(Color::Label, SecondaryLabel, Accent, Separator,
SystemRed/Orange/Green/Yellow) and resolve against the active theme.
Theme::native() / native_dark() use a translucent background so the OS
vibrancy material (NSVisualEffectView on macOS, DWM acrylic on Windows) shows
through; GNOME is a flat opaque fill (Linux/X11 has no vibrancy).
Forced-theme font resolution. A forced platform look wants the target OS's
UI font, so muri resolves fonts in tiers and never silently substitutes the
host's own UI face (which would draw the wrong OS's font): (1) the real OEM family
(Segoe UI / SF Pro / Cantarell) if it happens to be installed; (2) with the
opt-in bundled-fonts feature, the vendored OSS substitute (Selawik / Inter /
Cantarell); (3) a free, broadly-available fallback (DejaVu Sans, Liberation Sans,
…). Pixel-perfect parity therefore requires the real target face to be installed —
muri's contract is to never lie about it.
Error handling
muri's fallible operations return a typed Result<T, Error> — it returns
errors, it does not log (there is no log/tracing dependency; a consumer
decides what to surface):
Error::Unsupported(Unsupported)— a genuine per-platform impossibility, surfaced deliberately:Unsupported::TrayAnchor(a styled tray-anchored popup on Linux) andUnsupported::ClientPositioning(client-side toplevel positioning, which Wayland forbids).Error::BadIcon(String)— the supplied icon bytes could not be decoded.Error::Platform(String)— a platform API call failed while creating, installing, or anchoring the tray/surface; the message names the concrete failure site (e.g. a failedShell_NotifyIcon(NIM_ADD)/ SNI registration, or a main-thread requirement). A Windows/Linux tray-install failure is surfaced synchronously through the tray-thread install handshake (viaTrayIconBuilder::build_result) rather than returning a falseOk.
Performance
muri is built for instant popup open — a menu that appears the frame you click it. That drives two choices:
- CPU raster, not GPU. The popup is drawn on the CPU (
swashglyphs + muri's own AA blitter) and blitted to the window. There is no GPU warm-up (adapter init / shader compilation, ~hundreds of ms cold), which a transient popup can't hide. Menus are tiny, so the CPU draw is sub-millisecond, and vibrancy blur is the OS compositor's GPU work behind our transparent surface — we get it for free. - Event-driven, cached. The popup repaints only on state change (no idle redraw loop) and reuses a per-frame glyph cache (zero re-rasterization on repaint).
Recommended release profile for an embedding app (muri is a library, so its own profile doesn't apply to your binary — mirror this):
[]
= 3 # speed, not size — size-tuning hurts open latency
= "fat"
= 1
= true
Roadmap
All three backends are implemented; the remaining pre-1.0 work is on-device verification and screen-reader validation.
- macOS backend (done) —
NSStatusItem-anchored custom-drawn panel, flush alignment, flyout submenus, transient dismiss, dark mode, keyboard navigation, a parallel accessibility tree, and an attached AccessKit platform adapter (a live VoiceOver validation pass on a device remains). - Windows backend (done) —
WS_EX_NOACTIVATElayered window anchored viaShell_NotifyIconGetRect, DWM acrylic, theme-follow,WH_MOUSE_LL/WH_KEYBOARD_LLdismiss + keyboard, UIA viaaccesskit_windows(NVDA + Narrator validation remains). - Linux backend (done) — SNI/AppIndicator native
dbusmenu+ pointer-anchoredContextMenuon X11; AT-SPI via the native menu (Orca validation remains). Awlr-layer-shellanchored backend is a possible future community opt-in (wlroots/KWin only).
Minimum supported Rust version
muri's MSRV is 1.87, verified in CI by a dedicated job that runs cargo check
on 1.87. Lint, format, and tests run on the latest stable toolchain (so
Clippy always uses current lints) — only the MSRV build is pinned.
The floor is set by the Linux SNI tray's zbus D-Bus stack, which requires
1.87; the macOS/Windows/core trees build on 1.85, but the crate-wide contract is
the higher of the two, since a current, maintained D-Bus crate is the right
dependency for the Linux tray. Any MSRV change is a documented, deliberate
decision, not an accident of a transitive bump.
Contributing
Contributions are welcome — see CONTRIBUTING.md for the
build/test workflow and the fmt + clippy + test + doc quality gate, and
CODE_OF_CONDUCT.md for community expectations. CI runs on
the dev → qa → main flow, so a change is validated before it reaches
main.
Security
To report a vulnerability, follow the private disclosure process in SECURITY.md (GitHub security advisories, or the email listed there). Please do not open a public issue for security reports.
Changelog
Notable changes are recorded in CHANGELOG.md.
License
Licensed under the MIT License. © Matthew Jackson
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in muri by you shall be licensed as above, without any additional terms or conditions.