Menu
Public repository / 3aae9f4e85241bcb775c31f5c03094becd3da56a
AkurAI-Framework / crates/ui/src/dom.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
//! Views, elements and rendering.
//!
//! Components are plain functions returning something [`IntoView`] — an
//! [`El`] built with the chainable element API, text, a tuple or `Vec` of
//! views, or a closure. A closure is a *dynamic region*: it re-renders, in
//! place between two comment markers, whenever a signal it reads changes.
//!
//! ```
//! use akurai_ui::prelude::*;
//!
//! fn counter() -> impl IntoView {
//! let count = signal(0);
//! div().class("flex gap-2").child((
//! button().on_click(move |_| count.update(|c| *c += 1)).child("+1"),
//! span().child(move || count.get().to_string()),
//! ))
//! }
//! # let _ = counter;
//! ```
use crate::callbacks;
use crate::host::{self, EventRef, Node};
use crate::reactive::{
child_scope, current_scope, effect, memo, on_cleanup, signal, untrack, Scope, Signal,
};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::hash::Hash;
use std::rc::Rc;
type MountFn = Box<dyn FnOnce(Node, Option<Node>)>;
/// Something that can be mounted into the DOM.
pub enum View {
Empty,
Node(Node),
Many(Vec<View>),
/// Custom mount logic: `(parent, insert_before)`.
Mount(MountFn),
}
/// Conversion into a [`View`] — the React "renderable" idea.
pub trait IntoView {
fn into_view(self) -> View;
}
impl IntoView for View {
fn into_view(self) -> View {
self
}
}
impl IntoView for () {
fn into_view(self) -> View {
View::Empty
}
}
impl IntoView for &str {
fn into_view(self) -> View {
View::Node(host::create_text(self))
}
}
impl IntoView for String {
fn into_view(self) -> View {
self.as_str().into_view()
}
}
impl IntoView for &String {
fn into_view(self) -> View {
self.as_str().into_view()
}
}
impl<V: IntoView> IntoView for Option<V> {
fn into_view(self) -> View {
self.map_or(View::Empty, IntoView::into_view)
}
}
impl<V: IntoView> IntoView for Vec<V> {
fn into_view(self) -> View {
View::Many(self.into_iter().map(IntoView::into_view).collect())
}
}
impl<F, V> IntoView for F
where
F: Fn() -> V + 'static,
V: IntoView,
{
fn into_view(self) -> View {
dynamic(move || self().into_view())
}
}
macro_rules! tuple_into_view {
($($name:ident),+) => {
impl<$($name: IntoView),+> IntoView for ($($name,)+) {
#[allow(non_snake_case)]
fn into_view(self) -> View {
let ($($name,)+) = self;
View::Many(vec![$($name.into_view()),+])
}
}
};
}
tuple_into_view!(A);
tuple_into_view!(A, B);
tuple_into_view!(A, B, C);
tuple_into_view!(A, B, C, D);
tuple_into_view!(A, B, C, D, E);
tuple_into_view!(A, B, C, D, E, G);
tuple_into_view!(A, B, C, D, E, G, H);
tuple_into_view!(A, B, C, D, E, G, H, I);
macro_rules! display_into_view {
($($t:ty),+) => {$(
impl IntoView for $t {
fn into_view(self) -> View {
self.to_string().into_view()
}
}
)+};
}
display_into_view!(i32, i64, u32, u64, usize, f64);
/// Insert `view` into `parent`, before `before` (or at the end).
pub fn mount(parent: Node, view: View, before: Option<Node>) {
match view {
View::Empty => {}
View::Node(n) => host::insert_before(parent, n, before),
View::Many(views) => {
for v in views {
mount(parent, v, before);
}
}
View::Mount(f) => f(parent, before),
}
}
fn markers(parent: Node, before: Option<Node>) -> (Node, Node) {
let start = host::create_comment("");
let end = host::create_comment("");
host::insert_before(parent, start, before);
host::insert_before(parent, end, before);
(start, end)
}
/// A region that re-renders `render` whenever a signal it reads changes.
pub fn dynamic(render: impl Fn() -> View + 'static) -> View {
View::Mount(Box::new(move |parent, before| {
let (start, end) = markers(parent, before);
effect(move || {
let view = render();
host::clear_between(start, end);
if let Some(p) = host::parent(end) {
mount(p, view, Some(end));
}
});
}))
}
/// Render `then` while `when` is true, `otherwise` while it is false. The
/// condition is memoised, so the branch only rebuilds when it flips.
pub fn show<V: IntoView, W: IntoView>(
when: impl Fn() -> bool + 'static,
then: impl Fn() -> V + 'static,
otherwise: impl Fn() -> W + 'static,
) -> View {
let cond = memo(when);
dynamic(move || {
if cond.get() {
untrack(|| then().into_view())
} else {
untrack(|| otherwise().into_view())
}
})
}
/// Render `then` only while `when` is true.
pub fn when<V: IntoView>(
when: impl Fn() -> bool + 'static,
then: impl Fn() -> V + 'static,
) -> View {
show(when, then, || ())
}
struct Row<T: 'static> {
item: Signal<T>,
scope: Scope,
start: Node,
end: Node,
}
/// A keyed list. Rows are created once per key and kept (with their DOM and
/// state) across updates; a changed item is pushed into the row's signal.
pub fn each<T, K, V>(
items: impl Fn() -> Vec<T> + 'static,
key: impl Fn(&T) -> K + 'static,
render: impl Fn(Signal<T>) -> V + 'static,
) -> View
where
T: Clone + PartialEq + 'static,
K: Eq + Hash + Clone + 'static,
V: IntoView,
{
View::Mount(Box::new(move |parent, before| {
let (_start, end) = markers(parent, before);
let owner = current_scope();
let rows: Rc<RefCell<HashMap<K, Row<T>>>> = Rc::new(RefCell::new(HashMap::new()));
on_cleanup({
let rows = rows.clone();
move || {
rows.borrow_mut()
.drain()
.for_each(|(_, r)| r.scope.dispose())
}
});
effect(move || {
let list = items();
untrack(|| {
let mut old = std::mem::take(&mut *rows.borrow_mut());
let mut order = Vec::with_capacity(list.len());
let mut fresh = HashMap::with_capacity(list.len());
for item in list {
let k = key(&item);
if fresh.contains_key(&k) {
continue;
}
let row = match old.remove(&k) {
Some(row) => {
row.item.set_if_changed(item);
row
}
None => new_row(owner, end, item, &render),
};
order.push(k.clone());
fresh.insert(k, row);
}
for (_, gone) in old {
host::clear_between(gone.start, gone.end);
host::remove(gone.start);
host::remove(gone.end);
gone.scope.dispose();
}
let mut anchor = end;
for k in order.iter().rev() {
let row = &fresh[k];
if host::next_sibling(row.end) != Some(anchor) {
host::move_range(row.start, row.end, anchor);
}
anchor = row.start;
}
*rows.borrow_mut() = fresh;
});
});
}))
}
fn new_row<T: 'static, V: IntoView>(
owner: Option<Scope>,
end: Node,
item: T,
render: &impl Fn(Signal<T>) -> V,
) -> Row<T> {
let build = || {
child_scope(|| {
let item = signal(item);
let parent = host::parent(end).expect("list end marker is attached");
let (start, row_end) = markers(parent, Some(end));
mount(parent, render(item).into_view(), Some(row_end));
(item, start, row_end)
})
};
let ((item, start, end), scope) = match owner {
Some(o) => o.run(build),
None => build(),
};
Row {
item,
scope,
start,
end,
}
}
/// Mount `view` at the end of `<body>` instead of in place (dialogs, toasts).
/// It is removed when the owning scope is disposed.
pub fn portal(view: impl IntoView + 'static) -> View {
View::Mount(Box::new(move |_, _| {
let (start, end) = markers(host::BODY, None);
mount(host::BODY, view.into_view(), Some(end));
on_cleanup(move || {
host::clear_between(start, end);
host::remove(start);
host::remove(end);
});
}))
}
// ── Events ──────────────────────────────────────────────────────────────────
/// The DOM event being handled.
#[derive(Clone, Copy, Debug)]
pub struct Event(pub(crate) EventRef);
impl Event {
/// `event.target.value` (inputs, selects, textareas).
pub fn value(&self) -> String {
host::event_value(self.0)
}
/// `event.target.checked`.
pub fn checked(&self) -> bool {
host::event_checked(self.0)
}
/// `event.key` for keyboard events.
pub fn key(&self) -> String {
host::event_key(self.0)
}
pub fn shift_key(&self) -> bool {
host::event_shift(self.0)
}
pub fn target(&self) -> Option<Node> {
host::event_target(self.0)
}
pub fn prevent_default(&self) {
host::prevent_default(self.0)
}
pub fn stop_propagation(&self) {
host::stop_propagation(self.0)
}
}
/// Wrap `f` so it runs with `owner` as the current scope, giving handlers the
/// same context (`use_context`) as the component that registered them.
pub(crate) fn in_scope<A>(owner: Option<Scope>, mut f: impl FnMut(A)) -> impl FnMut(A) {
move |a| match owner {
Some(scope) => scope.run(|| f(a)),
None => f(a),
}
}
/// Listen for `event` on `node` for the lifetime of the current scope.
pub fn listen(node: Node, event: &str, handler: impl FnMut(Event) + 'static) {
let id = callbacks::on_event(in_scope(current_scope(), handler));
host::listen(node, event, id);
on_cleanup(move || callbacks::remove(id));
}
// ── Attributes & properties ─────────────────────────────────────────────────
/// A value for an attribute: text, presence (`bool`), optional, or reactive.
pub trait AttrValue {
fn apply(self, node: Node, name: &'static str);
}
impl AttrValue for &str {
fn apply(self, node: Node, name: &'static str) {
host::set_attr(node, name, self)
}
}
impl AttrValue for String {
fn apply(self, node: Node, name: &'static str) {
host::set_attr(node, name, &self)
}
}
impl AttrValue for &String {
fn apply(self, node: Node, name: &'static str) {
host::set_attr(node, name, self)
}
}
impl AttrValue for bool {
fn apply(self, node: Node, name: &'static str) {
if self {
host::set_attr(node, name, "")
} else {
host::remove_attr(node, name)
}
}
}
impl<V: AttrValue> AttrValue for Option<V> {
fn apply(self, node: Node, name: &'static str) {
match self {
Some(v) => v.apply(node, name),
None => host::remove_attr(node, name),
}
}
}
impl<F, V> AttrValue for F
where
F: Fn() -> V + 'static,
V: AttrValue,
{
fn apply(self, node: Node, name: &'static str) {
effect(move || self().apply(node, name));
}
}
/// A value for a DOM property (`value`, `checked`, `disabled`, …).
pub trait PropValue {
fn apply(self, node: Node, name: &'static str);
}
impl PropValue for &str {
fn apply(self, node: Node, name: &'static str) {
host::set_prop_str(node, name, self)
}
}
impl PropValue for String {
fn apply(self, node: Node, name: &'static str) {
host::set_prop_str(node, name, &self)
}
}
impl PropValue for bool {
fn apply(self, node: Node, name: &'static str) {
host::set_prop_bool(node, name, self)
}
}
impl<F, V> PropValue for F
where
F: Fn() -> V + 'static,
V: PropValue,
{
fn apply(self, node: Node, name: &'static str) {
effect(move || self().apply(node, name));
}
}
/// Captures an element's node for imperative use (focus, measuring).
#[derive(Clone, Default)]
pub struct NodeRef(Rc<Cell<Option<Node>>>);
impl NodeRef {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self) -> Option<Node> {
self.0.get()
}
pub fn focus(&self) {
if let Some(n) = self.get() {
host::focus(n);
}
}
}
// ── Elements ────────────────────────────────────────────────────────────────
/// An element under construction. Every method consumes and returns `self`,
/// so markup reads top-down like JSX.
#[must_use = "an element does nothing until it is mounted"]
pub struct El(Node);
/// Create an HTML element.
pub fn el(tag: &str) -> El {
El(host::create_element(tag))
}
/// Create an SVG element.
pub fn svg_el(tag: &str) -> El {
El(host::create_element_ns(host::SVG_NS, tag))
}
impl IntoView for El {
fn into_view(self) -> View {
View::Node(self.0)
}
}
impl El {
pub fn node(&self) -> Node {
self.0
}
pub fn attr(self, name: &'static str, value: impl AttrValue) -> Self {
value.apply(self.0, name);
self
}
pub fn prop(self, name: &'static str, value: impl PropValue) -> Self {
value.apply(self.0, name);
self
}
pub fn class(self, value: impl AttrValue) -> Self {
self.attr("class", value)
}
pub fn id(self, value: impl AttrValue) -> Self {
self.attr("id", value)
}
pub fn style(self, value: impl AttrValue) -> Self {
self.attr("style", value)
}
pub fn role(self, value: impl AttrValue) -> Self {
self.attr("role", value)
}
pub fn typ(self, value: impl AttrValue) -> Self {
self.attr("type", value)
}
pub fn child(self, view: impl IntoView) -> Self {
mount(self.0, view.into_view(), None);
self
}
pub fn children<V: IntoView>(self, views: impl IntoIterator<Item = V>) -> Self {
for v in views {
mount(self.0, v.into_view(), None);
}
self
}
pub fn on(self, event: &str, handler: impl FnMut(Event) + 'static) -> Self {
listen(self.0, event, handler);
self
}
pub fn on_click(self, handler: impl FnMut(Event) + 'static) -> Self {
self.on("click", handler)
}
/// `input` events, handed the field's current value.
pub fn on_input(self, mut handler: impl FnMut(String) + 'static) -> Self {
self.on("input", move |e| handler(e.value()))
}
/// `submit` with the browser's default navigation prevented.
pub fn on_submit(self, mut handler: impl FnMut(Event) + 'static) -> Self {
self.on("submit", move |e| {
e.prevent_default();
handler(e)
})
}
/// Two-way bind a text field to a signal.
pub fn bind(self, value: Signal<String>) -> Self {
self.prop("value", move || value.get())
.on_input(move |v| value.set(v))
}
/// Two-way bind a checkbox to a signal.
pub fn bind_checked(self, checked: Signal<bool>) -> Self {
self.prop("checked", move || checked.get())
.on("change", move |e| checked.set(e.checked()))
}
pub fn node_ref(self, r: &NodeRef) -> Self {
r.0.set(Some(self.0));
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::html::*;
use crate::reactive::create_root;
use crate::testing;
#[test]
fn static_markup_renders() {
testing::render(|| div().class("a").child((span().child("hi"), "!")));
assert_eq!(
testing::body_html(),
r#"<div class="a"><span>hi</span>!</div>"#
);
}
#[test]
fn dynamic_text_updates_in_place() {
let count = testing::render_with(|| {
let count = signal(1);
(p().child(move || count.get().to_string()), count)
});
assert_eq!(testing::body_html(), "<p>1</p>");
count.set(2);
assert_eq!(testing::body_html(), "<p>2</p>");
}
#[test]
fn click_handlers_update_state() {
testing::render(|| {
let n = signal(0);
button()
.on_click(move |_| n.update(|v| *v += 1))
.child(move || format!("n={}", n.get()))
});
let b = testing::find("button").unwrap();
testing::click(b);
testing::click(b);
assert_eq!(testing::text(b), "n=2");
}
#[test]
fn show_switches_branches_and_disposes_listeners() {
let open = testing::render_with(|| {
let open = signal(true);
(
div().child(show(
move || open.get(),
|| button().on_click(|_| {}).child("x"),
|| "closed",
)),
open,
)
});
let before = callbacks::live();
open.set(false);
assert_eq!(testing::body_html(), "<div>closed</div>");
assert_eq!(callbacks::live(), before - 1, "button listener released");
}
#[test]
fn keyed_each_preserves_rows_and_reorders() {
let items = testing::render_with(|| {
let items = signal(vec![(1, "a"), (2, "b"), (3, "c")]);
(
ul().child(each(
move || items.get(),
|(id, _)| *id,
|item| li().child(move || item.get().1),
)),
items,
)
});
assert_eq!(
testing::body_html(),
"<ul><li>a</li><li>b</li><li>c</li></ul>"
);
let first_li = testing::find("li").unwrap();
items.set(vec![(3, "c"), (1, "A")]);
assert_eq!(testing::body_html(), "<ul><li>c</li><li>A</li></ul>");
assert_eq!(testing::find_all("li")[1], first_li, "row 1 kept its node");
}
#[test]
fn bind_is_two_way() {
let name = testing::render_with(|| {
let name = signal(String::from("ada"));
(input().bind(name), name)
});
let field = testing::find("input").unwrap();
assert_eq!(testing::prop(field, "value"), "ada");
testing::input(field, "grace");
assert_eq!(name.get_untracked(), "grace");
}
#[test]
fn reactive_attributes_toggle() {
let on = testing::render_with(|| {
let on = signal(false);
(div().attr("data-on", move || on.get()), on)
});
assert_eq!(testing::body_html(), "<div></div>");
on.set(true);
assert_eq!(testing::body_html(), r#"<div data-on=""></div>"#);
}
#[test]
fn portal_mounts_into_body_and_cleans_up() {
let (_, scope) = create_root(|| {
testing::reset();
mount(
host::BODY,
div().child(portal(p().child("over"))).into_view(),
None,
);
});
assert_eq!(
testing::body_html(),
"<p>over</p><div></div>",
"portals mount when built"
);
scope.dispose();
assert_eq!(testing::body_html(), "<div></div>");
}
}