///|
/// Represents a system tray handle created by this package.
///
/// A `Tray` tracks the user-visible state that the MoonBit layer believes is
/// active, including whether the tray is currently visible, which tooltip is
/// being shown, and which icon path was last requested. The underlying native
/// resources are released by calling `destroy()`.
pub struct Tray {
priv mut handle : Int64
priv mut native : Bool
priv platform : Platform
priv identifier : String
priv mut icon : String?
priv mut tooltip : String
priv mut menu : Array[TrayMenuItem]
priv mut events : Array[TrayEvent]
priv mut visible : Bool
priv mut destroyed : Bool
}
///|
/// Kind of context-menu item supported by tray v1.
pub(all) enum TrayMenuItemKind {
Normal
Separator
Checkbox
Submenu
} derive(Debug, Eq)
///|
/// One tray context-menu item.
///
/// Use `TrayMenuItem::normal`, `TrayMenuItem::separator`, and
/// `TrayMenuItem::checkbox` to build clickable items. Use
/// `TrayMenuItem::submenu` to group nested items. Clickable item ids must be
/// unique across the whole menu tree.
pub(all) enum TrayMenuItem {
Normal(id~ : String, label~ : String, enabled~ : Bool)
Separator
Checkbox(id~ : String, label~ : String, checked~ : Bool, enabled~ : Bool)
Submenu(label~ : String, items~ : Array[TrayMenuItem], enabled~ : Bool)
} derive(Debug, Eq)
///|
/// Event emitted by the native tray backend.
pub(all) enum TrayEvent {
Click
RightClick
DoubleClick
MenuItemClick(String)
} derive(Debug, Eq)
///|
/// Builds a normal clickable menu item.
pub fn TrayMenuItem::normal(
id~ : String,
label~ : String,
enabled? : Bool = true,
) -> TrayMenuItem {
Normal(id~, label~, enabled~)
}
///|
/// Builds a separator menu item.
pub fn TrayMenuItem::separator() -> TrayMenuItem {
Separator
}
///|
/// Builds a checkbox menu item.
pub fn TrayMenuItem::checkbox(
id~ : String,
label~ : String,
checked? : Bool = false,
enabled? : Bool = true,
) -> TrayMenuItem {
Checkbox(id~, label~, checked~, enabled~)
}
///|
/// Builds a submenu containing nested menu items.
pub fn TrayMenuItem::submenu(
label~ : String,
items~ : Array[TrayMenuItem],
enabled? : Bool = true,
) -> TrayMenuItem {
Submenu(label~, items~, enabled~)
}
///|
/// Returns the item kind.
pub fn TrayMenuItem::kind(self : TrayMenuItem) -> TrayMenuItemKind {
match self {
Normal(_) => Normal
Separator => Separator
Checkbox(_) => Checkbox
Submenu(_) => Submenu
}
}
///|
/// Returns the wire-format event name for a tray event.
pub fn TrayEvent::event_name(self : TrayEvent) -> String {
match self {
Click => "click"
RightClick => "rightClick"
DoubleClick => "doubleClick"
MenuItemClick(_) => "menuItemClick"
}
}
///|
/// Returns the clicked menu item id for `MenuItemClick` events.
pub fn TrayEvent::item_id(self : TrayEvent) -> String? {
match self {
MenuItemClick(id) => Some(id)
_ => None
}
}
///|
/// Returns whether the native backend can create tray instances on the current
/// machine.
///
/// On Windows this is expected to be `true`. On other platforms, or when the
/// required desktop runtime is unavailable, this returns `false`. This probe is
/// side-effect free from the MoonBit caller's perspective and is useful for
/// gating UI paths that would otherwise call `create()`.
///
/// This function answers only whether tray support appears available right now;
/// it does not allocate a tray handle or make one visible. If you need a human
/// readable explanation for a `false` result, call `ensure_supported()` instead.
///
/// # Example
/// ```mbt check
/// test "is_supported probes capability without creating a tray" {
/// let _ : Bool = is_supported()
/// }
/// ```
pub fn is_supported() -> Bool {
is_supported_ffi()
}
///|
/// Validates that the native backend is available before any tray is created.
///
/// Use this when an application wants to show an actionable startup error
/// instead of deferring the failure until `create()`. The error string is
/// produced by the native backend when available, so callers can surface a
/// platform-specific explanation to users.
///
/// Returns `Ok(())` when tray creation should be possible on the current
/// machine. Returns `Err(message)` when the backend is missing, the desktop
/// runtime is unavailable, or the platform is unsupported.
pub fn ensure_supported() -> Result[Unit, String] {
if is_supported() {
Ok(())
} else {
Err(support_error_message())
}
}
///|
/// Creates a tray handle with an optional icon path and initial tooltip.
///
/// - `identifier` should be a stable, non-empty id for the tray instance.
/// - `icon` may be `None` to request the platform default tray icon.
/// - `tooltip` becomes the initial hover text when the platform supports it.
///
/// The returned handle starts hidden, so callers can finish any last setup and
/// then call `show()`. Empty or whitespace-only identifiers are normalized back
/// to `default_identifier()`, and failures include the latest native error when
/// the backend provides one.
///
/// On success this returns `Ok(tray)` with a live handle that can be shown,
/// hidden, updated, pumped, and eventually destroyed. On failure this returns
/// `Err(message)` with either the support-probe failure or the most recent
/// backend creation error.
///
/// Native tray operations, including `destroy()`, must run on the thread that
/// called `create()`; macOS already required this, and it is now enforced on
/// all platforms.
///
/// # Example
/// ```mbt nocheck
/// let tray = @tray.create(icon=Some("/path/to/icon.png"), tooltip="My App").unwrap()
/// let _ = tray.show()
/// // ... run the application event loop, calling `tray.pump()` as needed ...
/// tray.destroy()
/// ```
pub fn create(
identifier? : String = default_identifier(),
icon? : String? = None,
tooltip? : String = "",
) -> Result[Tray, String] {
match ensure_supported() {
Err(error) => Err(error)
Ok(_) => {
let normalized_identifier = normalize_identifier(identifier)
match validate_c_text("identifier", normalized_identifier) {
Err(error) => return Err(error)
Ok(_) => ()
}
match validate_optional_c_text("icon", icon) {
Err(error) => return Err(error)
Ok(_) => ()
}
match validate_c_text("tooltip", tooltip) {
Err(error) => return Err(error)
Ok(_) => ()
}
let handle = create_state_ffi(
encode_text(normalized_identifier),
encode_optional_text(icon),
encode_text(tooltip),
)
if handle == zero_handle() {
Err(
decode_bytes(last_create_error_ffi()).unwrap_or("tray.create failed"),
)
} else {
Ok({
handle,
native: true,
platform: current_platform(),
identifier: normalized_identifier,
icon,
tooltip,
menu: [],
events: [],
visible: false,
destroyed: false,
})
}
}
}
}
///|
/// Returns the stable identifier associated with this tray handle.
pub fn Tray::identifier(self : Tray) -> String {
self.identifier
}
///|
/// Returns the platform reported when this tray handle was created.
pub fn Tray::platform(self : Tray) -> Platform {
self.platform
}
///|
/// Returns the last icon path requested through `create()` or `set_icon()`.
pub fn Tray::icon(self : Tray) -> String? {
self.icon
}
///|
/// Returns the last tooltip requested through `create()`, `show()`, or
/// `set_tooltip()`.
pub fn Tray::tooltip(self : Tray) -> String {
self.tooltip
}
///|
/// Returns whether this tray handle is currently believed to be visible.
pub fn Tray::is_visible(self : Tray) -> Bool {
self.visible
}
///|
/// Alias for `is_visible()`.
pub fn Tray::visible(self : Tray) -> Bool {
self.visible
}
///|
/// Returns a clone of the last menu payload accepted by `set_menu()`.
pub fn Tray::menu_items(self : Tray) -> Array[TrayMenuItem] {
clone_menu_items(self.menu)
}
///|
/// Shows the tray icon and optionally replaces the tooltip in the same call.
///
/// Passing `tooltip=Some(...)` is the most efficient way to update the tooltip
/// immediately before the tray becomes visible. The result reports the tray's
/// visible state after the call, and invoking this on a simulated test tray
/// updates only the MoonBit-side state.
///
/// Passing `tooltip=None` reuses the most recently stored tooltip. Successful
/// calls always resolve to `Ok(true)`. If the tray has already been destroyed,
/// or if the native backend rejects the operation, this returns `Err(message)`.
pub fn Tray::show(
self : Tray,
tooltip? : String? = None,
) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) => {
let next_tooltip = tooltip.unwrap_or(self.tooltip)
match validate_c_text("tooltip", next_tooltip) {
Err(error) => return Err(error)
Ok(_) => ()
}
if !self.native {
self.tooltip = next_tooltip
self.visible = true
Ok(true)
} else if show_state_ffi(self.handle, encode_text(next_tooltip)) {
self.tooltip = next_tooltip
self.visible = true
Ok(true)
} else {
Err(self.state_error(self.handle, "tray.show failed"))
}
}
}
}
///|
/// Hides the tray icon while keeping the handle valid for later `show()` calls.
///
/// The returned boolean reflects the post-call visibility state, so successful
/// calls resolve to `Ok(false)` whether the tray is native or simulated.
///
/// This is safe to call even when the tray is already hidden; the handle stays
/// valid and can be shown again later. Destroyed trays still reject the call
/// with an error.
pub fn Tray::hide(self : Tray) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) =>
if !self.native {
self.visible = false
Ok(false)
} else if hide_state_ffi(self.handle) {
self.visible = false
Ok(false)
} else {
Err(self.state_error(self.handle, "tray.hide failed"))
}
}
}
///|
/// Replaces the current tooltip text without changing visibility.
///
/// Platforms that cannot show a real tooltip may map this value to the nearest
/// native concept available to the host desktop environment. The returned
/// boolean mirrors whether the tray is visible after the update.
///
/// Hidden trays keep the new tooltip so the next `show()` call reuses it by
/// default. Successful calls return the current visible state, while destroyed
/// trays or backend failures return `Err(message)`.
pub fn Tray::set_tooltip(self : Tray, tooltip : String) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) =>
match validate_c_text("tooltip", tooltip) {
Err(error) => Err(error)
Ok(_) =>
if !self.native {
self.tooltip = tooltip
Ok(self.visible)
} else if set_tooltip_ffi(self.handle, encode_text(tooltip)) {
self.tooltip = tooltip
Ok(self.visible)
} else {
Err(self.state_error(self.handle, "tray.set_tooltip failed"))
}
}
}
}
///|
/// Changes the tray icon path or resets it to the platform default when `None`
/// is passed.
///
/// This updates the stored icon preference even for simulated trays used in
/// tests, and the returned boolean mirrors whether the tray is visible after
/// the change.
///
/// Pass `Some(path)` to request a specific icon file, or `None` to fall back to
/// the backend's default tray icon. Successful calls return the current visible
/// state; destroyed trays and backend failures return `Err(message)`.
pub fn Tray::set_icon(self : Tray, icon : String?) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) =>
match validate_optional_c_text("icon", icon) {
Err(error) => Err(error)
Ok(_) =>
if !self.native {
self.icon = icon
Ok(self.visible)
} else if set_icon_ffi(self.handle, encode_optional_text(icon)) {
self.icon = icon
Ok(self.visible)
} else {
Err(self.state_error(self.handle, "tray.set_icon failed"))
}
}
}
}
///|
/// Replaces the context menu shown for this tray icon.
///
/// The menu supports `normal`, `separator`, `checkbox`, and nested `submenu`
/// items. Normal and checkbox items must have non-empty `id` and `label`
/// values; item ids must be unique across the whole menu tree so click events
/// can be routed reliably.
///
/// Successful calls return the current visible state. Unsupported operating
/// systems, missing native menu backends, or invalid menu payloads return
/// `Err(message)`.
pub fn Tray::set_menu(
self : Tray,
items : Array[TrayMenuItem],
) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) =>
match validate_menu(items) {
Err(error) => Err(error)
Ok(_) => {
let stored_items = clone_menu_items(items)
if !self.native {
self.menu = stored_items
Ok(self.visible)
} else if apply_menu_transaction(self.handle, items) {
self.menu = stored_items
Ok(self.visible)
} else {
Err(self.state_error(self.handle, "tray.set_menu failed"))
}
}
}
}
}
///|
/// Drains all currently queued tray events.
///
/// For native trays, this polls the backend event queue until it is empty. For
/// simulated trays used in tests, this returns and clears the MoonBit-side
/// event queue. Destroyed trays return an empty array.
pub fn Tray::drain_events(self : Tray) -> Array[TrayEvent] {
if self.destroyed {
return []
}
if !self.native {
let drained = self.events
self.events = []
return drained
}
let drained : Array[TrayEvent] = []
let mut polling = true
while polling {
match decode_bytes(poll_event_json_ffi(self.handle)) {
Some(raw) =>
match decode_event(raw) {
Some(event) => drained.push(event)
None => ()
}
None => polling = false
}
}
drained
}
///|
/// Pumps one native tray loop iteration.
///
/// Call this from long-running native applications when the host platform needs
/// event-loop progress from the tray backend. A return value of `Ok(false)`
/// means the backend asked to stop processing. Simulated trays always return
/// `Ok(true)` so unit tests can exercise state transitions without a native
/// message loop.
///
/// Passing `blocking=true` lets the backend wait for work before returning;
/// `blocking=false` performs at most one non-blocking iteration. Backend errors
/// and calls on destroyed trays return `Err(message)`.
pub fn Tray::pump(
self : Tray,
blocking? : Bool = false,
) -> Result[Bool, String] {
match self.ensure_alive() {
Err(error) => Err(error)
Ok(_) =>
if !self.native {
Ok(true)
} else {
let status = pump_state_ffi(self.handle, if blocking { 1 } else { 0 })
if status > 0 {
Ok(true)
} else if status == 0 {
Ok(false)
} else {
Err(self.state_error(self.handle, "tray.pump failed"))
}
}
}
}
///|
/// Releases the underlying native resources and turns the handle into a no-op
/// object that rejects later operations.
///
/// Calling `destroy()` more than once is safe; repeated calls are ignored after
/// the first teardown has marked the handle as destroyed.
///
/// After destruction the tray becomes permanently unusable: `show()`, `hide()`,
/// `set_tooltip()`, `set_icon()`, `set_menu()`, and `pump()` will all return
/// errors instead of touching the native backend again.
///
/// Native tray operations, including `destroy()`, must run on the thread that
/// called `create()`; macOS already required this, and it is now enforced on
/// all platforms.
pub fn Tray::destroy(self : Tray) -> Unit {
if self.destroyed {
return ()
}
if self.native {
destroy_state_ffi(self.handle)
}
self.handle = zero_handle()
self.native = false
self.visible = false
self.events = []
self.destroyed = true
}
///|
/// Validates all menu items before state or native resources are changed.
fn validate_menu(items : Array[TrayMenuItem]) -> Result[Unit, String] {
let seen_ids : Array[String] = []
validate_menu_items(items, seen_ids, depth=0)
}
///|
/// Maximum submenu depth accepted by the native transaction builder.
fn max_menu_depth() -> Int {
8
}
///|
/// Maximum UTF-8 bytes accepted for a clickable item id.
fn max_menu_item_id_bytes() -> Int {
128
}
///|
/// Maximum clickable menu items addressable by native command ids.
fn max_menu_clickable_items() -> Int {
64
}
///|
/// Validates a menu subtree and tracks clickable ids globally.
fn validate_menu_items(
items : Array[TrayMenuItem],
seen_ids : Array[String],
depth~ : Int,
) -> Result[Unit, String] {
if depth > max_menu_depth() {
return Err("tray menu submenu depth exceeds 8")
}
for item in items {
match item {
Normal(id~, label~, ..) | Checkbox(id~, label~, ..) =>
match validate_clickable_menu_item(id, label, seen_ids) {
Err(error) => return Err(error)
Ok(_) => ()
}
Separator => ()
Submenu(label~, items~, ..) => {
if label.trim().to_owned().is_empty() {
return Err("tray submenu label must not be empty")
}
if text_has_nul(label) {
return Err("tray submenu label must not contain NUL bytes")
}
match validate_menu_items(items, seen_ids, depth=depth + 1) {
Err(error) => return Err(error)
Ok(_) => ()
}
}
}
}
Ok(())
}
///|
/// Validates a clickable menu item and records its id.
fn validate_clickable_menu_item(
id : String,
label : String,
seen_ids : Array[String],
) -> Result[Unit, String] {
let normalized_id = id.trim().to_owned()
let normalized_label = label.trim().to_owned()
if normalized_id.is_empty() {
return Err("tray menu item id must not be empty")
}
if id != normalized_id {
return Err("tray menu item id must not have leading or trailing whitespace")
}
if text_has_nul(id) {
return Err("tray menu item id must not contain NUL bytes")
}
// encode_text appends a NUL terminator, so the encoded length is the C
// strlen + 1; `>` keeps the exact 127-accept / 128-reject strlen boundary
// required by the native side (strlen(id) < 128).
if encode_text(id).length() > max_menu_item_id_bytes() {
return Err("tray menu item id is too long")
}
if normalized_label.is_empty() {
return Err("tray menu item label must not be empty")
}
if text_has_nul(normalized_label) {
return Err("tray menu item label must not contain NUL bytes")
}
if seen_ids.contains(id) {
return Err("tray menu item id must be unique: " + id)
}
if seen_ids.length() >= max_menu_clickable_items() {
return Err("tray menu has too many clickable items")
}
seen_ids.push(id)
Ok(())
}
///|
/// Rejects embedded NUL because native FFI strings are C strings.
fn validate_c_text(field : String, value : String) -> Result[Unit, String] {
if text_has_nul(value) {
Err("tray " + field + " must not contain NUL bytes")
} else {
Ok(())
}
}
///|
/// Rejects embedded NUL in optional native FFI strings.
fn validate_optional_c_text(
field : String,
value : String?,
) -> Result[Unit, String] {
match value {
Some(text) => validate_c_text(field, text)
None => Ok(())
}
}
///|
/// Returns whether text contains an embedded C string terminator.
fn text_has_nul(value : String) -> Bool {
for c in value {
if c == '\u{0}' {
return true
}
}
false
}
///|
/// Clones menu items before storing caller-provided arrays in tray state.
fn clone_menu_items(items : Array[TrayMenuItem]) -> Array[TrayMenuItem] {
let cloned : Array[TrayMenuItem] = []
for item in items {
cloned.push(
match item {
Normal(id~, label~, enabled~) => Normal(id~, label~, enabled~)
Separator => Separator
Checkbox(id~, label~, checked~, enabled~) =>
Checkbox(id~, label~, checked~, enabled~)
Submenu(label~, items~, enabled~) =>
Submenu(label~, items=clone_menu_items(items), enabled~)
},
)
}
cloned
}
///|
/// Applies a native menu replacement transaction.
fn apply_menu_transaction(handle : Int64, items : Array[TrayMenuItem]) -> Bool {
if !menu_begin_ffi(handle) {
return false
}
if append_menu_items(handle, items) && menu_commit_ffi(handle) {
true
} else {
menu_abort_ffi(handle)
false
}
}
///|
/// Appends a menu subtree to the active native transaction.
fn append_menu_items(handle : Int64, items : Array[TrayMenuItem]) -> Bool {
for item in items {
let ok = match item {
Normal(id~, label~, enabled~) =>
menu_add_normal_ffi(
handle,
encode_text(id),
encode_text(label),
bool_to_int(enabled),
)
Separator => menu_add_separator_ffi(handle)
Checkbox(id~, label~, checked~, enabled~) =>
menu_add_checkbox_ffi(
handle,
encode_text(id),
encode_text(label),
bool_to_int(enabled),
bool_to_int(checked),
)
Submenu(label~, items~, enabled~) =>
if menu_begin_submenu_ffi(
handle,
encode_text(label),
bool_to_int(enabled),
) {
if append_menu_items(handle, items) {
menu_end_submenu_ffi(handle)
} else {
false
}
} else {
false
}
}
if !ok {
return false
}
}
true
}
///|
/// Converts a MoonBit boolean to the C ABI integer convention.
fn bool_to_int(value : Bool) -> Int {
if value {
1
} else {
0
}
}
///|
/// Decodes one native tray event JSON payload.
fn decode_event(raw : String) -> TrayEvent? {
try @json.parse(raw) catch {
_ => None
} noraise {
json =>
match json {
Object(object) =>
match object.get("type") {
Some(String("click")) => Some(Click)
Some(String("rightClick")) => Some(RightClick)
Some(String("doubleClick")) => Some(DoubleClick)
Some(String("menuItemClick")) =>
match object.get("item_id") {
Some(String(item_id)) => Some(MenuItemClick(item_id))
_ => None
}
_ => None
}
_ => None
}
}
}
///|
/// Rejects operations performed on a tray handle after it has been destroyed.
fn Tray::ensure_alive(self : Tray) -> Result[Unit, String] {
if self.destroyed {
Err("tray handle has been destroyed")
} else {
Ok(())
}
}
///|
/// Decodes the last native state error or falls back to the provided message.
fn Tray::state_error(_self : Tray, handle : Int64, fallback : String) -> String {
decode_bytes(last_state_error_ffi(handle)).unwrap_or(fallback)
}
///|
/// Encodes UTF-8 text as a NUL-terminated C string for the native backend.
fn encode_text(value : String) -> Bytes {
@ffi.to_cstr(value)
}
///|
/// Encodes optional text as a NUL-terminated C string, empty for `None`.
fn encode_optional_text(value : String?) -> Bytes {
@ffi.to_cstr(value.unwrap_or(""))
}
///|
/// Decodes non-empty UTF-8 byte payloads into strings.
fn decode_bytes(bytes : Bytes) -> String? {
if bytes.is_empty() {
None
} else {
Some(@utf8.decode_lossy(bytes))
}
}
///|
/// Returns the sentinel handle value used for simulated or destroyed trays.
fn zero_handle() -> Int64 {
Int64::from_int(0)
}