htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
//! Browser-aligned DOM bindings over the Boa engine.
//!
//! Exposes a small DOM surface to scripts:
//! - the global `document` (`getElementById`, `createElement`, `body`);
//! - element wrappers with `textContent`, `style`, `className`, `id`,
//!   `parentNode`, `getAttribute`/`setAttribute`/`removeAttribute`,
//!   `addEventListener`/`removeEventListener`, `appendChild`/`removeChild`;
//! - the `console` object (logs through `log`);
//! - `alert` / `quit` built-ins.
//!
//! Every native here is a `Copy` closure or plain fn pointer (Boa forbids
//! non-`Copy` captures without `unsafe`); shared engine state is fetched from
//! the `thread_local!` registry installed by [`super::ScriptEngine`].

mod attrs;
mod builtins;
mod console;
mod content;
mod document;
mod events;
mod fetch;
mod global_attrs;
mod mutation;
mod style;
mod timers;
mod traverse;
mod view;
mod wrapper;

// Kept reachable for `super::dom::…` imports from engine.rs / bindings.rs /
// msg.rs (crate-internal API surface); not every item is used today.
pub(crate) use builtins::install_builtins;
#[allow(unused_imports)]
pub(crate) use events::{
    add_listener, clear_attr_handler, clear_node_attr_handler, clear_node_listeners,
    get_attr_handler, get_listeners, make_event_object, parse_event_type, remove_listener,
    set_attr_handler,
};
pub(crate) use fetch::response_object;
pub(crate) use view::{get_window_listeners, mql_last_matches, mql_matches_now, mql_query};

use boa_engine::{
    Context, JsNativeError, JsObject, JsResult, JsValue, NativeFunction, js_string,
    object::{FunctionObjectBuilder, ObjectInitializer, builtins::JsFunction},
};
use slotmap::Key;

use crate::tdom::node::{NodeArena, NodeId, NodeType};

use super::ScriptState;

use wrapper::make_node_wrapper;

/// Fetch the shared engine state (arena, render queue, …).
///
/// Natives call this at the top of every entry point. Returns a clone of the
/// shared `Rc` so the borrow of the `thread_local` is short-lived.
pub(crate) fn with_state() -> Option<std::rc::Rc<std::cell::RefCell<ScriptState>>> {
    super::ENGINE_STATE.with(|s| s.borrow().clone())
}

/// Record a DOM mutation: remember the node so the render thread can repaint
/// the smallest region that covers its affected boxes.
pub(crate) fn mark_dirty(st: &mut ScriptState, node_id: NodeId) {
    st.dirty.nodes.push(node_id);
}

/// Convert a `NodeId` into a JS-safe `f64` (opaque, round-trippable).
pub(crate) fn node_id_to_f64(id: NodeId) -> f64 {
    Key::data(&id).as_ffi() as f64
}

/// Recover a `NodeId` from the `f64` stored on an element wrapper.
pub(crate) fn f64_to_node_id(v: f64) -> NodeId {
    u64_to_node_id(v as u64)
}

/// Recover a `NodeId` from its opaque slotmap bits (the shared UI state
/// carries the focused field's id this way).
pub(crate) fn u64_to_node_id(v: u64) -> NodeId {
    slotmap::KeyData::from_ffi(v).into()
}

/// Read `_nodeId` off a `this` object.
fn this_node_id(this: &JsValue, context: &mut Context) -> JsResult<NodeId> {
    let obj = this
        .to_object(context)
        .map_err(|_| JsNativeError::typ().with_message("expected an element object"))?;
    let v = obj.get(js_string!("_nodeId"), context)?;
    let ffi = v
        .to_number(context)
        .map_err(|_| JsNativeError::typ().with_message("element has no _nodeId"))?;
    Ok(f64_to_node_id(ffi))
}

/// Build the JS function object for a native fn pointer.
fn js_fn(context: &mut Context, native: NativeFunction) -> JsFunction {
    FunctionObjectBuilder::new(context.realm(), native).build()
}

/// Create (or return the cached) JS wrapper for any DOM node (`node_id`).
///
/// Wrappers are cached on the JS side (`globalThis.__htuiElements[nodeId]`) so
/// `el === event.target` identity checks work; one builder serves every node
/// kind (elements, text, comments, the document root, fragments).
pub(crate) fn node_wrapper(context: &mut Context, node_id: NodeId) -> JsResult<JsValue> {
    let key = node_id_to_f64(node_id);
    let registry = registry_elements(context)?;
    let cached = registry.get(key, context)?;
    if !cached.is_undefined() {
        return Ok(cached);
    }
    let wrapper = make_node_wrapper(context, node_id)?;
    registry.set(key, wrapper.clone(), false, context)?;
    Ok(wrapper)
}

/// The JS-side element cache object.
fn registry_elements(context: &mut Context) -> JsResult<JsObject> {
    let global = context.global_object();
    let obj = global.get(js_string!("__htuiElements"), context)?;
    match obj.to_object(context) {
        Ok(o) => Ok(o),
        Err(_) => {
            let mut init = ObjectInitializer::new(context);
            let o = init.build();
            global.set(js_string!("__htuiElements"), o.clone(), false, context)?;
            Ok(o)
        }
    }
}

/// Whether a JS wrapper is cached for `node_id` in `__htuiElements`.
///
/// The cache is GC-rooted and written by every [`node_wrapper`] call, so
/// this is the conservative "JS may still hold this node" oracle used by
/// the `innerHTML` sweep (see `content::sweep_replaced_subtrees`).
pub(super) fn wrapper_is_cached(context: &mut Context, node_id: NodeId) -> JsResult<bool> {
    let key = node_id_to_f64(node_id);
    let cached = registry_elements(context)?.get(key, context)?;
    Ok(!cached.is_undefined())
}

// ============================================================
// Helpers
// ============================================================

/// Concatenate all descendant `Text` nodes of `node_id` (browser
/// `textContent`). A text node's own data is included — the browser returns
/// it for `textContent` on the node itself — so this wraps
/// [`Node::text_content`](crate::tdom::node::Node::text_content) (descendants
/// only) with the self case.
fn collect_text(arena: &NodeArena, node_id: NodeId) -> String {
    let Some(node) = arena.get(node_id) else {
        return String::new();
    };
    if node.node_type == NodeType::Text {
        return node.data.clone().unwrap_or_default();
    }
    node.text_content(arena)
}