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}");
}