Skip to main content

native/guppy_nif/src/lib.rs

mod bridge_text_input;
mod bridge_view;
mod etf_decode;
mod ir;
mod ir_allowed;
mod main_thread_runtime;
mod menu;
mod native_events;
mod window_options;

use crate::ir::IrNode;
use crate::menu::{MenuItemSpec, MenuSpec};
use crate::window_options::WindowOptionsConfig;
use rustler::{Atom, Encoder, Env, LocalPid, Monitor, OwnedBinary, Resource, ResourceArc, Term};
use std::ffi::{CString, c_char, c_void};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};

#[cfg(target_os = "macos")]
type ErlNifTid = *mut c_void;

#[cfg(target_os = "macos")]
// SAFETY: This declaration binds OTP's main-thread bootstrap hook. The only call site is the
// narrow, documented unsafe block in maybe_start_main_thread_runtime.
unsafe extern "C" {
    fn erl_drv_steal_main_thread(
        name: *mut c_char,
        dtid: *mut ErlNifTid,
        func: extern "C" fn(*mut c_void) -> *mut c_void,
        arg: *mut c_void,
        opts: *mut c_void,
    ) -> i32;
}

rustler::atoms! {
    action,
    alt,
    app_activated,
    app_deactivated,
    blur,
    button,
    callback,
    change,
    checked,
    click,
    close,
    click_count,
    column_id,
    context_menu,
    control,
    delta_kind,
    delta_x,
    dock_menu_action,
    delta_y,
    direction,
    data_table_cell_click,
    data_table_column_reorder,
    data_table_column_resize,
    data_table_row_click,
    data_table_sort,
    decode_error,
    drag_move,
    drag_start,
    drop,
    duplicate_view_id,
    first_mouse,
    focus,
    function,
    guppy_native_event,
    hover,
    hovered,
    id,
    is_held,
    item_id,
    key,
    key_char,
    key_down,
    key_up,
    left,
    lines,
    list_id,
    menu_action,
    menus_decode_error,
    middle,
    modifiers,
    native_timeout,
    mouse_down,
    mouse_move,
    mouse_up,
    navigate_back,
    navigate_forward,
    nil,
    none,
    options_decode_error,
    pixels,
    platform,
    pong,
    pressed_button,
    right,
    row_id,
    runtime_unavailable,
    rust_core_unavailable,
    scroll_wheel,
    shift,
    shortcut,
    source_id,
    some,
    table_id,
    target_column_id,
    tree_id,
    tree_select,
    tree_toggle,
    unknown_view_id,
    value,
    control_id,
    window_blurred,
    window_close_requested,
    window_closed,
    window_focused,
    window_moved,
    window_resized,
    width,
    width_delta,
    height,
    x,
    y,
}

static RUNTIME_RUNNING: AtomicBool = AtomicBool::new(false);
static GUI_STARTED: AtomicBool = AtomicBool::new(false);
static GUI_STATUS: Mutex<i32> = Mutex::new(0);
static GUI_STATUS_COND: Condvar = Condvar::new();
static EVENT_TARGET: Mutex<Option<EventTargetRegistration>> = Mutex::new(None);
static EVENT_TARGET_GENERATION: AtomicU64 = AtomicU64::new(0);
// BEAM dirty scheduler stacks are small enough for debug recursive IR decode to overflow on
// ordinary UI trees. Decode ETF-backed IR on a dedicated worker with a larger stack, then hand the
// native IR back to the dirty scheduler for timeout-aware main-thread dispatch.
static IR_DECODE_WORKER: Mutex<Option<Sender<IrDecodeJob>>> = Mutex::new(None);
const IR_DECODE_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
static OPEN_IR_TO_BINARY_COUNT: AtomicU64 = AtomicU64::new(0);
static OPEN_IR_TO_BINARY_NANOS: AtomicU64 = AtomicU64::new(0);
static OPEN_IR_DECODE_COUNT: AtomicU64 = AtomicU64::new(0);
static OPEN_IR_DECODE_NANOS: AtomicU64 = AtomicU64::new(0);
static OPEN_OPTIONS_DECODE_COUNT: AtomicU64 = AtomicU64::new(0);
static OPEN_OPTIONS_DECODE_NANOS: AtomicU64 = AtomicU64::new(0);
static RENDER_IR_TO_BINARY_COUNT: AtomicU64 = AtomicU64::new(0);
static RENDER_IR_TO_BINARY_NANOS: AtomicU64 = AtomicU64::new(0);
static RENDER_IR_DECODE_COUNT: AtomicU64 = AtomicU64::new(0);
static RENDER_IR_DECODE_NANOS: AtomicU64 = AtomicU64::new(0);
static NATIVE_EVENT_SEND_COUNT: AtomicU64 = AtomicU64::new(0);
static NATIVE_EVENT_SEND_NANOS: AtomicU64 = AtomicU64::new(0);
static NATIVE_EVENT_SEND_FAILURE_COUNT: AtomicU64 = AtomicU64::new(0);

#[cfg(target_os = "macos")]
static GUI_THREAD: Mutex<Option<usize>> = Mutex::new(None);

fn load(env: Env, _term: Term) -> bool {
    if env.register::<EventTargetMonitor>().is_err() {
        return false;
    }

    main_thread_runtime::init_request_queue();
    RUNTIME_RUNNING.store(true, Ordering::SeqCst);
    let _ = maybe_start_main_thread_runtime();
    true
}

#[rustler::nif]
fn native_ping() -> Atom {
    pong()
}

#[rustler::nif]
fn native_build_info() -> &'static str {
    "guppy_nif_rust_core"
}

#[rustler::nif]
fn native_runtime_status() -> &'static str {
    if RUNTIME_RUNNING.load(Ordering::SeqCst) {
        "started"
    } else {
        "not_started"
    }
}

#[cfg(target_os = "macos")]
#[rustler::nif]
fn native_gui_status() -> &'static str {
    if GUI_STARTED.load(Ordering::SeqCst) {
        "started"
    } else {
        "failed"
    }
}

#[cfg(not(target_os = "macos"))]
#[rustler::nif]
fn native_gui_status() -> &'static str {
    "unsupported"
}

#[rustler::nif]
fn native_performance_counters<'a>(env: Env<'a>) -> Term<'a> {
    let pairs = vec![
        counter_pair(env, "open_ir_to_binary_count", &OPEN_IR_TO_BINARY_COUNT),
        counter_pair(
            env,
            "open_ir_to_binary_native_time_ns",
            &OPEN_IR_TO_BINARY_NANOS,
        ),
        counter_pair(env, "open_ir_decode_count", &OPEN_IR_DECODE_COUNT),
        counter_pair(env, "open_ir_decode_native_time_ns", &OPEN_IR_DECODE_NANOS),
        counter_pair(env, "open_options_decode_count", &OPEN_OPTIONS_DECODE_COUNT),
        counter_pair(
            env,
            "open_options_decode_native_time_ns",
            &OPEN_OPTIONS_DECODE_NANOS,
        ),
        counter_pair(env, "render_ir_to_binary_count", &RENDER_IR_TO_BINARY_COUNT),
        counter_pair(
            env,
            "render_ir_to_binary_native_time_ns",
            &RENDER_IR_TO_BINARY_NANOS,
        ),
        counter_pair(env, "render_ir_decode_count", &RENDER_IR_DECODE_COUNT),
        counter_pair(
            env,
            "render_ir_decode_native_time_ns",
            &RENDER_IR_DECODE_NANOS,
        ),
        counter_pair(env, "native_event_send_count", &NATIVE_EVENT_SEND_COUNT),
        counter_pair(
            env,
            "native_event_send_native_time_ns",
            &NATIVE_EVENT_SEND_NANOS,
        ),
        counter_pair(
            env,
            "native_event_send_failure_count",
            &NATIVE_EVENT_SEND_FAILURE_COUNT,
        ),
    ];

    map_from_pairs(env, pairs)
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_open_window<'a>(
    env: Env<'a>,
    view_id: u64,
    ir: Term<'a>,
    opts: Term<'a>,
    timeout_ms: u64,
) -> Term<'a> {
    let to_binary_started_at = Instant::now();
    let ir_binary = ir.to_binary();
    record_counter(
        &OPEN_IR_TO_BINARY_COUNT,
        &OPEN_IR_TO_BINARY_NANOS,
        to_binary_started_at.elapsed(),
    );

    let opts_binary = opts.to_binary();

    let options_decode_started_at = Instant::now();
    let options = match WindowOptionsConfig::decode_etf(opts_binary.as_slice()) {
        Ok(options) => options,
        Err(reason) => return error_reason_tuple(env, options_decode_error(), reason),
    };
    record_counter(
        &OPEN_OPTIONS_DECODE_COUNT,
        &OPEN_OPTIONS_DECODE_NANOS,
        options_decode_started_at.elapsed(),
    );

    let ir = match decode_ir_binary(ir_binary, &OPEN_IR_DECODE_COUNT, &OPEN_IR_DECODE_NANOS) {
        Ok(ir) => ir,
        Err(reason) => return error_reason_tuple(env, decode_error(), reason),
    };

    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::OpenWindow {
            deadline,
            view_id,
            ir,
            options,
            reply,
        }
    });

    status_result(env, result, duplicate_view_id())
}

#[rustler::nif]
fn native_set_event_target<'a>(env: Env<'a>, pid: LocalPid) -> Term<'a> {
    let generation = EVENT_TARGET_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
    let resource = ResourceArc::new(EventTargetMonitor { generation });
    let Some(monitor) = env.monitor(&resource, &pid) else {
        return error_tuple(env, runtime_unavailable());
    };

    let Ok(mut target) = EVENT_TARGET.lock() else {
        return error_tuple(env, runtime_unavailable());
    };

    *target = Some(EventTargetRegistration {
        pid,
        generation,
        _resource: resource,
        _monitor: Some(monitor),
    });

    rustler::types::atom::ok().encode(env)
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_set_menus<'a>(env: Env<'a>, menus: Term<'a>, timeout_ms: u64) -> Term<'a> {
    let menus_binary = menus.to_binary();
    let menus = match MenuSpec::decode_etf(menus_binary.as_slice()) {
        Ok(menus) => menus,
        Err(reason) => return error_reason_tuple(env, menus_decode_error(), reason),
    };

    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::SetMenus {
            deadline,
            menus,
            reply,
        }
    });

    status_result(env, result, runtime_unavailable())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_set_dock_menu<'a>(env: Env<'a>, items: Term<'a>, timeout_ms: u64) -> Term<'a> {
    let items_binary = items.to_binary();
    let items = match MenuItemSpec::decode_list_etf(items_binary.as_slice()) {
        Ok(items) => items,
        Err(reason) => return error_reason_tuple(env, menus_decode_error(), reason),
    };

    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::SetDockMenu {
            deadline,
            items,
            reply,
        }
    });

    status_result(env, result, runtime_unavailable())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_set_app_badge<'a>(env: Env<'a>, label: Option<String>, timeout_ms: u64) -> Term<'a> {
    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::SetAppBadge {
            deadline,
            label,
            reply,
        }
    });

    status_result(env, result, runtime_unavailable())
}

#[rustler::nif]
fn native_event_target_status<'a>(env: Env<'a>) -> Term<'a> {
    let Ok(target) = EVENT_TARGET.lock() else {
        return error_tuple(env, runtime_unavailable());
    };

    match target.as_ref() {
        Some(target) => (some(), target.generation).encode(env),
        None => none().encode(env),
    }
}

#[rustler::nif(schedule = "DirtyIo")]
#[allow(clippy::too_many_arguments)]
fn native_open_file_dialog<'a>(
    env: Env<'a>,
    files: bool,
    directories: bool,
    multiple: bool,
    prompt: Option<String>,
    directory: Option<String>,
    filters: Vec<String>,
    owner_view_id: Option<u64>,
    timeout_ms: u64,
) -> Term<'a> {
    let result = request_with_timeout(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::OpenFileDialog {
            deadline,
            files,
            directories,
            multiple,
            prompt,
            directory,
            filters,
            owner_view_id,
            reply,
        }
    });

    match result {
        NativeRequestResult::Reply(Ok(Some(paths))) => paths.encode(env),
        NativeRequestResult::Reply(Ok(None)) => nil().encode(env),
        NativeRequestResult::Reply(Err(())) => error_tuple(env, runtime_unavailable()),
        NativeRequestResult::Timeout => error_tuple(env, native_timeout()),
        NativeRequestResult::Unavailable => error_tuple(env, runtime_unavailable()),
    }
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_save_file_dialog<'a>(
    env: Env<'a>,
    directory: Option<String>,
    default_name: Option<String>,
    filters: Vec<String>,
    owner_view_id: Option<u64>,
    timeout_ms: u64,
) -> Term<'a> {
    let result = request_with_timeout(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::SaveFileDialog {
            deadline,
            directory,
            default_name,
            filters,
            owner_view_id,
            reply,
        }
    });

    match result {
        NativeRequestResult::Reply(Ok(Some(path))) => path.encode(env),
        NativeRequestResult::Reply(Ok(None)) => nil().encode(env),
        NativeRequestResult::Reply(Err(())) => error_tuple(env, runtime_unavailable()),
        NativeRequestResult::Timeout => error_tuple(env, native_timeout()),
        NativeRequestResult::Unavailable => error_tuple(env, runtime_unavailable()),
    }
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_read_clipboard_text<'a>(env: Env<'a>, timeout_ms: u64) -> Term<'a> {
    let result = request_with_timeout(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::ReadClipboardText { deadline, reply }
    });

    match result {
        NativeRequestResult::Reply(Ok(Some(text))) => text.encode(env),
        NativeRequestResult::Reply(Ok(None)) => nil().encode(env),
        NativeRequestResult::Reply(Err(())) => error_tuple(env, runtime_unavailable()),
        NativeRequestResult::Timeout => error_tuple(env, native_timeout()),
        NativeRequestResult::Unavailable => error_tuple(env, runtime_unavailable()),
    }
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_write_clipboard_text<'a>(env: Env<'a>, text: String, timeout_ms: u64) -> Term<'a> {
    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::WriteClipboardText {
            deadline,
            text,
            reply,
        }
    });

    status_result(env, result, runtime_unavailable())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_render<'a>(env: Env<'a>, view_id: u64, ir: Term<'a>, timeout_ms: u64) -> Term<'a> {
    let to_binary_started_at = Instant::now();
    let ir_binary = ir.to_binary();
    record_counter(
        &RENDER_IR_TO_BINARY_COUNT,
        &RENDER_IR_TO_BINARY_NANOS,
        to_binary_started_at.elapsed(),
    );

    let ir = match decode_ir_binary(ir_binary, &RENDER_IR_DECODE_COUNT, &RENDER_IR_DECODE_NANOS) {
        Ok(ir) => ir,
        Err(reason) => return error_reason_tuple(env, decode_error(), reason),
    };

    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::SetIr {
            deadline,
            view_id,
            ir,
            reply,
        }
    });

    status_result(env, result, unknown_view_id())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_close_window<'a>(env: Env<'a>, view_id: u64, timeout_ms: u64) -> Term<'a> {
    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::CloseWindow {
            deadline,
            view_id,
            reply,
        }
    });

    status_result(env, result, unknown_view_id())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_focus_window<'a>(env: Env<'a>, view_id: u64, timeout_ms: u64) -> Term<'a> {
    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::FocusWindow {
            deadline,
            view_id,
            reply,
        }
    });

    status_result(env, result, unknown_view_id())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_close_all<'a>(env: Env<'a>, timeout_ms: u64) -> Term<'a> {
    let result = request_i32(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::CloseAll { deadline, reply }
    });

    status_result(env, result, runtime_unavailable())
}

#[rustler::nif(schedule = "DirtyIo")]
fn native_view_count<'a>(env: Env<'a>, timeout_ms: u64) -> Term<'a> {
    match request_u64(timeout_ms, |reply, deadline| {
        main_thread_runtime::MainThreadRequest::ViewCount { deadline, reply }
    }) {
        NativeRequestResult::Reply(count) => count.encode(env),
        NativeRequestResult::Timeout => error_tuple(env, native_timeout()),
        NativeRequestResult::Unavailable => error_tuple(env, runtime_unavailable()),
    }
}

struct IrDecodeJob {
    // OwnedBinary is Send and process-independent, so the ETF payload moves
    // to the decode worker without copying the full tree.
    binary: OwnedBinary,
    reply: Sender<Result<IrNode, String>>,
}

fn decode_ir_binary(
    binary: OwnedBinary,
    count: &AtomicU64,
    nanos: &AtomicU64,
) -> Result<IrNode, String> {
    let started_at = Instant::now();
    let decoded = decode_ir_binary_on_worker(binary);
    record_counter(count, nanos, started_at.elapsed());
    decoded
}

fn decode_ir_binary_on_worker(binary: OwnedBinary) -> Result<IrNode, String> {
    let (reply_tx, reply_rx) = mpsc::channel();
    let mut job = Some(IrDecodeJob {
        binary,
        reply: reply_tx,
    });

    for _ in 0..2 {
        let sender = ir_decode_worker_sender()?;
        let current_job = job
            .take()
            .expect("ir decode retry loop must retain unsent job");

        match sender.send(current_job) {
            Ok(()) => {
                return reply_rx
                    .recv()
                    .map_err(|_| "ir decode worker stopped before replying".to_owned())?;
            }
            Err(error) => {
                job = Some(error.0);
                clear_ir_decode_worker();
            }
        }
    }

    Err("ir decode worker unavailable".into())
}

fn ir_decode_worker_sender() -> Result<Sender<IrDecodeJob>, String> {
    let mut worker = IR_DECODE_WORKER
        .lock()
        .map_err(|_| "ir decode worker lock poisoned".to_owned())?;

    if let Some(sender) = worker.as_ref() {
        return Ok(sender.clone());
    }

    let (sender, receiver) = mpsc::channel();
    std::thread::Builder::new()
        .name("guppy-ir-decode".into())
        .stack_size(IR_DECODE_STACK_SIZE_BYTES)
        .spawn(move || ir_decode_worker_loop(receiver))
        .map_err(|error| format!("failed to start ir decode worker: {error}"))?;

    *worker = Some(sender.clone());
    Ok(sender)
}

fn clear_ir_decode_worker() {
    if let Ok(mut worker) = IR_DECODE_WORKER.lock() {
        *worker = None;
    }
}

fn ir_decode_worker_loop(receiver: Receiver<IrDecodeJob>) {
    for job in receiver {
        let result = IrNode::decode_etf(job.binary.as_slice());
        let _ = job.reply.send(result);
    }
}

fn status_result<'a>(
    env: Env<'a>,
    result: NativeRequestResult<i32>,
    zero_reason: Atom,
) -> Term<'a> {
    match result {
        NativeRequestResult::Reply(1) => rustler::types::atom::ok().encode(env),
        NativeRequestResult::Reply(0) => error_tuple(env, zero_reason),
        NativeRequestResult::Reply(_) => error_tuple(env, runtime_unavailable()),
        NativeRequestResult::Timeout => error_tuple(env, native_timeout()),
        NativeRequestResult::Unavailable => error_tuple(env, runtime_unavailable()),
    }
}

fn error_tuple<'a>(env: Env<'a>, reason: Atom) -> Term<'a> {
    (rustler::types::atom::error(), reason).encode(env)
}

fn error_reason_tuple<'a>(env: Env<'a>, kind: Atom, reason: String) -> Term<'a> {
    (rustler::types::atom::error(), (kind, reason)).encode(env)
}

struct EventTargetRegistration {
    pid: LocalPid,
    generation: u64,
    _resource: ResourceArc<EventTargetMonitor>,
    _monitor: Option<Monitor>,
}

struct EventTargetMonitor {
    generation: u64,
}

impl Resource for EventTargetMonitor {
    const IMPLEMENTS_DOWN: bool = true;

    fn down<'a>(&'a self, _env: Env<'a>, pid: LocalPid, _monitor: Monitor) {
        let Ok(mut target) = EVENT_TARGET.lock() else {
            return;
        };

        if matches!(target.as_ref(), Some(current) if current.generation == self.generation && current.pid == pid)
        {
            *target = None;
            let _ = main_thread_runtime::enqueue_request(
                main_thread_runtime::MainThreadRequest::CloseAllNoReply,
            );
        }
    }
}

enum NativeRequestResult<T> {
    Reply(T),
    Timeout,
    Unavailable,
}

fn request_i32(
    timeout_ms: u64,
    build: impl FnOnce(
        Sender<i32>,
        main_thread_runtime::RequestDeadline,
    ) -> main_thread_runtime::MainThreadRequest,
) -> NativeRequestResult<i32> {
    request_with_timeout(timeout_ms, build)
}

fn request_u64(
    timeout_ms: u64,
    build: impl FnOnce(
        Sender<u64>,
        main_thread_runtime::RequestDeadline,
    ) -> main_thread_runtime::MainThreadRequest,
) -> NativeRequestResult<u64> {
    request_with_timeout(timeout_ms, build)
}

fn request_with_timeout<T>(
    timeout_ms: u64,
    build: impl FnOnce(
        Sender<T>,
        main_thread_runtime::RequestDeadline,
    ) -> main_thread_runtime::MainThreadRequest,
) -> NativeRequestResult<T> {
    let (reply_tx, reply_rx) = mpsc::channel();
    let timeout = Duration::from_millis(timeout_ms);
    let deadline = main_thread_runtime::RequestDeadline::after(timeout);

    if main_thread_runtime::enqueue_request(build(reply_tx, deadline)).is_err() {
        return NativeRequestResult::Unavailable;
    }

    match reply_rx.recv_timeout(timeout) {
        Ok(value) => NativeRequestResult::Reply(value),
        Err(mpsc::RecvTimeoutError::Timeout) => NativeRequestResult::Timeout,
        Err(mpsc::RecvTimeoutError::Disconnected) => NativeRequestResult::Unavailable,
    }
}

fn maybe_start_main_thread_runtime() -> bool {
    if GUI_STARTED.load(Ordering::SeqCst) {
        return true;
    }

    #[cfg(target_os = "macos")]
    {
        {
            let mut status = GUI_STATUS.lock().expect("gui status lock poisoned");
            *status = 0;
        }

        let mut thread_id: ErlNifTid = std::ptr::null_mut();
        let name = CString::new("guppy_gpui").expect("static thread name has no nul");
        // SAFETY: OTP exposes erl_drv_steal_main_thread for NIF/bootstrap code, and Rustler does
        // not provide an equivalent safe abstraction for this macOS main-thread handoff. Keep this
        // block narrow: the thread name is a live CString for the duration of the call, dtid points
        // to valid stack storage, and the callback has the required extern "C" ABI.
        let result = unsafe {
            erl_drv_steal_main_thread(
                name.as_ptr().cast_mut(),
                &mut thread_id,
                run_main_thread_runtime,
                std::ptr::null_mut(),
                std::ptr::null_mut(),
            )
        };

        if result != 0 {
            return false;
        }

        {
            let mut slot = GUI_THREAD.lock().expect("gui thread lock poisoned");
            *slot = Some(thread_id as usize);
        }

        let mut status = GUI_STATUS.lock().expect("gui status lock poisoned");
        while *status == 0 {
            status = GUI_STATUS_COND
                .wait(status)
                .expect("gui status condvar poisoned");
        }

        let started = *status == 1;
        GUI_STARTED.store(started, Ordering::SeqCst);
        started
    }

    #[cfg(not(target_os = "macos"))]
    {
        // GPUI bootstrap is only wired for the macOS main-thread handoff
        // today. Leave GUI_STARTED false so status stays honest and requests
        // fail fast instead of timing out against a queue nothing drains.
        false
    }
}

#[cfg(target_os = "macos")]
extern "C" fn run_main_thread_runtime(_arg: *mut c_void) -> *mut c_void {
    main_thread_runtime::run_app();
    std::ptr::null_mut()
}

pub(crate) fn notify_gui_started(status: i32) {
    let mut gui_status = GUI_STATUS.lock().expect("gui status lock poisoned");
    *gui_status = status;
    GUI_STATUS_COND.notify_all();
}

pub(crate) use native_events::{
    send_dock_menu_action_event, send_menu_action_event, send_window_close_requested_event,
    send_window_closed_event,
};

fn map_from_pairs<'a>(env: Env<'a>, pairs: impl AsRef<[(Term<'a>, Term<'a>)]>) -> Term<'a> {
    match Term::map_from_pairs(env, pairs.as_ref()) {
        Ok(term) => term,
        Err(_) => rustler::types::atom::undefined().encode(env),
    }
}

fn counter_pair<'a>(env: Env<'a>, key: &'static str, counter: &AtomicU64) -> (Term<'a>, Term<'a>) {
    (key.encode(env), counter.load(Ordering::Relaxed).encode(env))
}

fn record_counter(count: &AtomicU64, nanos: &AtomicU64, duration: Duration) {
    count.fetch_add(1, Ordering::Relaxed);
    nanos.fetch_add(duration_to_u64_nanos(duration), Ordering::Relaxed);
}

fn record_event_send(started_at: Instant, sent: bool) {
    record_counter(
        &NATIVE_EVENT_SEND_COUNT,
        &NATIVE_EVENT_SEND_NANOS,
        started_at.elapsed(),
    );

    if !sent {
        NATIVE_EVENT_SEND_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
    }
}

fn duration_to_u64_nanos(duration: Duration) -> u64 {
    duration.as_nanos().min(u128::from(u64::MAX)) as u64
}

#[cfg(test)]
mod native_event_test_support;

#[cfg(test)]
use native_event_test_support::{
    record_basic_event_snapshot_for_test, record_menu_event_snapshot_for_test,
    record_row_control_event_snapshot_for_test, record_semantic_event_snapshot_for_test,
};

#[cfg(test)]
pub(crate) use native_event_test_support::{
    native_event_send_snapshot_for_test, take_basic_event_snapshot_matching_for_test,
    take_menu_event_snapshot_for_test, take_row_control_event_snapshot_for_test,
    take_semantic_event_snapshot_for_test, take_semantic_event_snapshot_matching_for_test,
};

rustler::init!("Elixir.Guppy.Native.Nif", load = load);