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 pre-1.0 and not yet released on crates.io (a 0.0.0 placeholder may
exist as the name is reserved). Depend on it from git until the first real
release:
[]
# Pre-1.0 / not yet released on crates.io — use a git dependency:
= { = "https://github.com/MattJackson/muri" }
# (with accessibility) muri = { git = "https://github.com/MattJackson/muri", features = ["a11y"] }
# Once published on crates.io:
# muri = "0.1"
Status: macOS-first WIP. On macOS the crate draws a real styled popup:
Tray::runinstalls theNSStatusItem, opens the custom-drawn menu anchored to it, opens flyout submenu panels, follows dark/light, and now 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 anaccesskit_winitadapter to the popup window so that tree is exposed to NSAccessibility / VoiceOver (a live screen-reader validation pass is the remaining human hop). The Windows backend so far installs the notification-area icon and reports its anchor rectangle (compile-verified forx86_64-pc-windows-msvc); its popup event loop is next. The Linux tray/anchor path is a deliberate fallback (see below), not a styled tray popup.Pre-1.0: muri is
0.0.x. The public API may change between releases until the Windows backend lands and the surface stabilizes; pin an exact version.
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 SVG bytes).
- 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:
winit(windowing) +softbuffer/tiny-skia(CPU 2D raster) +cosmic-text(text/fonts). 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 — NSAccessibility (macOS), UIA (Windows), AT-SPI (Linux fallback) — targeted for v1.
- v1 OS order: macOS → Windows → Linux.
Feature flags
muri keeps its default surface minimal; optional integrations sit behind flags.
Only a11y ships today — the rest are on the roadmap and are marked honestly.
| Flag | Default | Description |
|---|---|---|
a11y |
off | AccessKit screen-reader bridge over the raster backend (NSAccessibility / UIA / AT-SPI). Ships today; planned to be on-by-default at 1.0. |
muda-compat |
off | A muda / tray-icon drop-in compatibility facade so existing callers can migrate with minimal churn. Planned — lands at M3. |
serde |
off | Optional serde derives on the menu data model (Menu, Row, Segment, …). Planned / optional — not yet wired. |
Quick example
use ;
let menu = new
.section_header
.row
.separator
.row;
let tray = new
.tooltip
.menu
.on_click;
// tray.run()?; // installs the tray icon + runs the event loop (works on macOS today; Windows/Linux WIP)
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.9", = ["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) · 🔬 code-complete, on-device verification pending (this is what the 0.9.0 testing release is for) · 🚧 planned before 1.0 · ❌ not offered (by design).
| 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 menu | ❌ tray-anchored (by design — see below); use pointer ContextMenu |
🔬 X11 override-redirect open_at (Wayland: Unsupported::ClientPositioning) |
🔬 Orca (AT-SPI via the native menu) |
Will 0.9.0 be all-green? 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 muri code that
builds but hasn't been exercised on real hardware yet — verifying them, and the
four screen readers, is exactly what the 0.9.0 testing release is for; 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 nativemuda/dbusmenutree (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
muri follows OS dark/light and accent by default (ThemeSource::FollowSystem)
and exposes a full Theme (colors, fonts, spacing, corner radius, row height,
column gap) for consumer overrides via ThemeSource::Custom(Theme). Colors are
either literal Color::Rgba(..) or semantic (Color::Label,
SecondaryLabel, Accent, SystemRed/Orange/…) which resolve against the
active theme (and to the matching NSColor on macOS). Theme::native() uses a
translucent background so the OS vibrancy material (NSVisualEffectView on macOS,
DWM acrylic on Windows) shows through.
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
- macOS backend —
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 (done; a live VoiceOver validation pass on a device remains). - Windows backend —
WS_EX_NOACTIVATElayered window anchored viaShell_NotifyIconGetRect, theme-follow, outside-click dismiss, UIA. - Linux — native-menu fallback + pointer-anchored
ContextMenu; AT-SPI via AccessKit. (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.