AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/host/native.rs

Latest change fee5b6c2cde89fa6bf8dc51068af7336b56a88b8 - akurai-ui: platform::open_url (hand a URL, e.g. a native shell's scheme, to the window) by Ólafur Búi Ólafsson

//! In-memory DOM serving the host API off-browser, so components and whole
//! apps are unit-tested with `cargo test`. Drive it through [`crate::testing`].

use super::{EventRef, Node};
use std::cell::RefCell;
use std::collections::HashMap;

#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Kind {
    Element(String),
    Text,
    Comment,
    Root,
}

#[derive(Clone, Debug)]
pub(crate) struct FakeNode {
    pub kind: Kind,
    pub parent: Option<u32>,
    pub children: Vec<u32>,
    pub attrs: Vec<(String, String)>,
    pub props: HashMap<String, String>,
    pub listeners: Vec<(String, u32)>,
    pub text: String,
}

impl FakeNode {
    fn new(kind: Kind) -> Self {
        FakeNode {
            kind,
            parent: None,
            children: Vec::new(),
            attrs: Vec::new(),
            props: HashMap::new(),
            listeners: Vec::new(),
            text: String::new(),
        }
    }

    pub fn attr(&self, name: &str) -> Option<&str> {
        self.attrs
            .iter()
            .find(|(k, _)| k == name)
            .map(|(_, v)| v.as_str())
    }
}

#[derive(Default, Clone, Debug)]
pub(crate) struct FakeEvent {
    pub target: u32,
    pub key: String,
    pub shift: bool,
    pub prevented: bool,
    pub stopped: bool,
}

/// A request the app made, awaiting a test-supplied response.
#[derive(Clone, Debug, PartialEq)]
pub struct PendingFetch {
    pub method: String,
    pub url: String,
    pub body: Option<String>,
    pub(crate) callback: u32,
}

/// An open `EventSource` in tests: feed it with `testing::push_event`.
#[derive(Clone, Debug)]
pub struct OpenStream {
    pub id: u32,
    pub url: String,
    /// The named events the app listens for (plus unnamed `message`).
    pub names: Vec<String>,
    pub(crate) callback: u32,
}

pub(crate) struct Dom {
    pub nodes: HashMap<u32, FakeNode>,
    pub next: u32,
    pub focused: Option<u32>,
    pub events: HashMap<u32, FakeEvent>,
    pub next_event: u32,
    pub fetches: Vec<PendingFetch>,
    pub streams: Vec<OpenStream>,
    pub next_stream: u32,
    /// `Some(callback)` while a recording waits for `testing::finish_recording`.
    pub recording: Option<bool>,
    pub recording_cb: Option<u32>,
    pub played: Vec<String>,
    pub stream: Option<(String, f64)>,
    pub video: Option<(String, f64)>,
    pub opened: Vec<String>,
    pub timers: Vec<(u32, u32)>,
    pub hash: String,
    pub storage: HashMap<String, String>,
    pub title: String,
    pub root_attrs: HashMap<String, String>,
    pub now: f64,
}

impl Dom {
    pub fn fresh() -> Self {
        let mut nodes = HashMap::new();
        nodes.insert(1, FakeNode::new(Kind::Root));
        nodes.insert(2, FakeNode::new(Kind::Element("body".into())));
        nodes.insert(3, FakeNode::new(Kind::Root));
        Dom {
            nodes,
            next: 100,
            focused: None,
            events: HashMap::new(),
            next_event: 1,
            fetches: Vec::new(),
            streams: Vec::new(),
            next_stream: 1,
            recording: None,
            recording_cb: None,
            played: Vec::new(),
            stream: None,
            video: None,
            opened: Vec::new(),
            timers: Vec::new(),
            hash: String::new(),
            storage: HashMap::new(),
            title: String::new(),
            root_attrs: HashMap::new(),
            // 2026-01-05T12:00:00Z, a Monday — deterministic test clock.
            now: 1_767_614_400_000.0,
        }
    }

    fn add(&mut self, node: FakeNode) -> Node {
        let id = self.next;
        self.next += 1;
        self.nodes.insert(id, node);
        Node(id)
    }

    fn detach(&mut self, id: u32) {
        if let Some(p) = self.nodes.get(&id).and_then(|n| n.parent) {
            if let Some(parent) = self.nodes.get_mut(&p) {
                parent.children.retain(|c| *c != id);
            }
        }
        if let Some(n) = self.nodes.get_mut(&id) {
            n.parent = None;
        }
    }

    fn release(&mut self, id: u32) {
        let children = self
            .nodes
            .get(&id)
            .map(|n| n.children.clone())
            .unwrap_or_default();
        for c in children {
            self.release(c);
        }
        if self.focused == Some(id) {
            self.focused = None;
        }
        self.nodes.remove(&id);
    }

    pub fn is_descendant(&self, ancestor: u32, mut id: u32) -> bool {
        loop {
            if id == ancestor {
                return true;
            }
            match self.nodes.get(&id).and_then(|n| n.parent) {
                Some(p) => id = p,
                None => return false,
            }
        }
    }

    fn focusable(&self, id: u32) -> bool {
        let Some(n) = self.nodes.get(&id) else {
            return false;
        };
        let Kind::Element(tag) = &n.kind else {
            return false;
        };
        if n.attr("disabled").is_some()
            || n.props.get("disabled").map(String::as_str) == Some("true")
        {
            return false;
        }
        match n.attr("tabindex") {
            Some("-1") => false,
            Some(_) => true,
            None => {
                matches!(tag.as_str(), "button" | "input" | "select" | "textarea")
                    || (tag == "a" && n.attr("href").is_some())
            }
        }
    }

    pub fn first_focusable(&self, id: u32) -> Option<u32> {
        let children = self.nodes.get(&id)?.children.clone();
        for c in children {
            if self.focusable(c) {
                return Some(c);
            }
            if let Some(found) = self.first_focusable(c) {
                return Some(found);
            }
        }
        None
    }
}

thread_local! {
    pub(crate) static DOM: RefCell<Dom> = RefCell::new(Dom::fresh());
}

pub(crate) fn with<R>(f: impl FnOnce(&mut Dom) -> R) -> R {
    DOM.with(|d| f(&mut d.borrow_mut()))
}

pub(crate) fn create_element(tag: &str) -> Node {
    with(|d| d.add(FakeNode::new(Kind::Element(tag.to_string()))))
}
pub(crate) fn create_element_ns(_ns: &str, tag: &str) -> Node {
    create_element(tag)
}
pub(crate) fn create_text(text: &str) -> Node {
    with(|d| {
        let mut n = FakeNode::new(Kind::Text);
        n.text = text.to_string();
        d.add(n)
    })
}
pub(crate) fn create_comment(text: &str) -> Node {
    with(|d| {
        let mut n = FakeNode::new(Kind::Comment);
        n.text = text.to_string();
        d.add(n)
    })
}
pub(crate) fn set_attr(node: Node, name: &str, value: &str) {
    with(|d| {
        if let Some(n) = d.nodes.get_mut(&node.0) {
            match n.attrs.iter_mut().find(|(k, _)| k == name) {
                Some(slot) => slot.1 = value.to_string(),
                None => n.attrs.push((name.to_string(), value.to_string())),
            }
        }
    })
}
pub(crate) fn remove_attr(node: Node, name: &str) {
    with(|d| {
        if let Some(n) = d.nodes.get_mut(&node.0) {
            n.attrs.retain(|(k, _)| k != name);
        }
    })
}
pub(crate) fn set_prop_str(node: Node, name: &str, value: &str) {
    with(|d| {
        if let Some(n) = d.nodes.get_mut(&node.0) {
            n.props.insert(name.to_string(), value.to_string());
        }
    })
}
pub(crate) fn set_prop_bool(node: Node, name: &str, value: bool) {
    set_prop_str(node, name, if value { "true" } else { "false" })
}
pub(crate) fn get_prop_str(node: Node, name: &str) -> String {
    with(|d| {
        d.nodes
            .get(&node.0)
            .and_then(|n| n.props.get(name).cloned())
            .unwrap_or_default()
    })
}
pub(crate) fn insert_before(parent: Node, child: Node, before: Option<Node>) {
    with(|d| {
        d.detach(child.0);
        let Some(p) = d.nodes.get_mut(&parent.0) else {
            return;
        };
        let at = before
            .and_then(|b| p.children.iter().position(|c| *c == b.0))
            .unwrap_or(p.children.len());
        p.children.insert(at, child.0);
        if let Some(c) = d.nodes.get_mut(&child.0) {
            c.parent = Some(parent.0);
        }
    })
}
pub(crate) fn remove(node: Node) {
    with(|d| {
        d.detach(node.0);
        d.release(node.0);
    })
}
fn sibling_range(d: &Dom, start: u32, end: u32) -> Vec<u32> {
    let Some(p) = d.nodes.get(&start).and_then(|n| n.parent) else {
        return Vec::new();
    };
    let kids = &d.nodes[&p].children;
    let (Some(a), Some(b)) = (
        kids.iter().position(|c| *c == start),
        kids.iter().position(|c| *c == end),
    ) else {
        return Vec::new();
    };
    kids[a..=b].to_vec()
}
pub(crate) fn clear_between(start: Node, end: Node) {
    with(|d| {
        let range = sibling_range(d, start.0, end.0);
        if range.len() > 2 {
            for id in &range[1..range.len() - 1] {
                d.detach(*id);
                d.release(*id);
            }
        }
    })
}
pub(crate) fn move_range(start: Node, end: Node, before: Node) {
    let (range, parent) = with(|d| {
        (
            sibling_range(d, start.0, end.0),
            d.nodes.get(&before.0).and_then(|n| n.parent),
        )
    });
    if let Some(p) = parent {
        for id in range {
            insert_before(Node(p), Node(id), Some(before));
        }
    }
}
pub(crate) fn parent(node: Node) -> Option<Node> {
    with(|d| d.nodes.get(&node.0).and_then(|n| n.parent).map(Node))
}
pub(crate) fn next_sibling(node: Node) -> Option<Node> {
    with(|d| {
        let p = d.nodes.get(&node.0)?.parent?;
        let kids = &d.nodes.get(&p)?.children;
        let i = kids.iter().position(|c| *c == node.0)?;
        kids.get(i + 1).copied().map(Node)
    })
}
pub(crate) fn listen(node: Node, event: &str, callback: u32) {
    with(|d| {
        if let Some(n) = d.nodes.get_mut(&node.0) {
            n.listeners.push((event.to_string(), callback));
        }
    })
}
pub(crate) fn get_by_id(id: &str) -> Option<Node> {
    with(|d| {
        let mut ids: Vec<&u32> = d.nodes.keys().collect();
        ids.sort();
        ids.into_iter()
            .find(|k| d.nodes[k].attr("id") == Some(id) && d.is_descendant(2, **k))
            .copied()
            .map(Node)
    })
}
pub(crate) fn focus(node: Node) {
    with(|d| {
        if d.nodes.contains_key(&node.0) {
            d.focused = Some(node.0);
        }
    })
}
pub(crate) fn focus_first(node: Node) -> bool {
    with(|d| match d.first_focusable(node.0) {
        Some(id) => {
            d.focused = Some(id);
            true
        }
        None => false,
    })
}
pub(crate) fn active_element() -> Option<Node> {
    with(|d| d.focused.map(Node))
}
pub(crate) fn contains(ancestor: Node, node: Node) -> bool {
    with(|d| d.is_descendant(ancestor.0, node.0))
}
fn event<R>(ev: EventRef, f: impl FnOnce(&mut FakeEvent) -> R) -> Option<R> {
    with(|d| d.events.get_mut(&ev.0).map(f))
}
pub(crate) fn event_value(ev: EventRef) -> String {
    event(ev, |e| e.target)
        .map(|t| get_prop_str(Node(t), "value"))
        .unwrap_or_default()
}
pub(crate) fn event_checked(ev: EventRef) -> bool {
    event(ev, |e| e.target)
        .map(|t| get_prop_str(Node(t), "checked") == "true")
        .unwrap_or(false)
}
pub(crate) fn event_key(ev: EventRef) -> String {
    event(ev, |e| e.key.clone()).unwrap_or_default()
}
pub(crate) fn event_target(ev: EventRef) -> Option<Node> {
    event(ev, |e| Node(e.target))
}
pub(crate) fn event_shift(ev: EventRef) -> bool {
    event(ev, |e| e.shift).unwrap_or(false)
}
pub(crate) fn prevent_default(ev: EventRef) {
    event(ev, |e| e.prevented = true);
}
pub(crate) fn stop_propagation(ev: EventRef) {
    event(ev, |e| e.stopped = true);
}
pub(crate) fn fetch(method: &str, url: &str, body: Option<&str>, callback: u32) {
    with(|d| {
        d.fetches.push(PendingFetch {
            method: method.to_string(),
            url: url.to_string(),
            body: body.map(str::to_string),
            callback,
        })
    })
}
pub(crate) fn set_timeout(ms: u32, callback: u32) {
    with(|d| d.timers.push((ms, callback)))
}
pub(crate) fn record_start() -> bool {
    with(|d| {
        d.recording = Some(true);
        true
    })
}
pub(crate) fn record_stop(callback: u32) {
    with(|d| {
        d.recording = None;
        d.recording_cb = Some(callback);
    })
}
pub(crate) fn play_audio(src: &str) {
    with(|d| d.played.push(src.to_string()))
}
pub(crate) fn stream_audio(src: &str, volume: f64) {
    with(|d| d.stream = (!src.is_empty()).then(|| (src.to_string(), volume)))
}
pub(crate) fn open_url(url: &str) {
    with(|d| d.opened.push(url.to_string()))
}
pub(crate) fn stream_video(src: &str, volume: f64) {
    with(|d| d.video = (!src.is_empty()).then(|| (src.to_string(), volume)))
}
pub(crate) fn event_source(url: &str, names: &str, callback: u32) -> u32 {
    with(|d| {
        let id = d.next_stream;
        d.next_stream += 1;
        d.streams.push(OpenStream {
            id,
            url: url.to_string(),
            names: names
                .split(',')
                .filter(|n| !n.is_empty())
                .map(str::to_string)
                .collect(),
            callback,
        });
        id
    })
}
pub(crate) fn event_source_close(id: u32) {
    with(|d| d.streams.retain(|s| s.id != id))
}
pub(crate) fn location_hash() -> String {
    with(|d| d.hash.clone())
}
pub(crate) fn set_location_hash(hash: &str) {
    with(|d| d.hash = hash.to_string());
    crate::testing::fire(super::WINDOW, "hashchange");
}
pub(crate) fn now_ms() -> f64 {
    with(|d| d.now)
}
pub(crate) fn tz_offset_minutes() -> i32 {
    0
}
pub(crate) fn storage_get(key: &str) -> Option<String> {
    with(|d| d.storage.get(key).cloned())
}
pub(crate) fn storage_set(key: &str, value: &str) {
    with(|d| {
        d.storage.insert(key.to_string(), value.to_string());
    })
}
pub(crate) fn set_title(title: &str) {
    with(|d| d.title = title.to_string())
}
pub(crate) fn set_root_attr(name: &str, value: &str) {
    with(|d| {
        d.root_attrs.insert(name.to_string(), value.to_string());
    })
}
pub(crate) fn log(message: &str) {
    eprintln!("[akurai-ui] {message}");
}