use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::rc::{Rc, Weak};
use rhai::{
Array, CustomType, Dynamic, EvalAltResult, FLOAT, FnPtr, INT, ImmutableString, Map,
NativeCallContext, Position, TypeBuilder,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
AssetId, ComponentInstancePath, HostCallback, MotionSource, OpaqueHandle, OverlayId,
OverlayKind, OverlayPlacement, PrimitiveNode, ScriptCallback, ScriptGeneration, Style,
UiEventBinding, UiEventHandler, UiValue,
};
#[derive(Clone, Debug, PartialEq)]
pub enum ImageSourceSpec {
Handle(OpaqueHandle),
Asset(AssetId),
}
#[derive(Clone, Debug, PartialEq)]
pub struct OverlayNodeSpec {
pub id: OverlayId,
pub parent: Option<OverlayId>,
pub kind: OverlayKind,
pub placement: OverlayPlacement,
pub anchor: Option<crate::OverlayBounds>,
pub open: bool,
pub gap: f64,
pub modal: bool,
pub dismiss: OverlayDismissPolicy,
pub tooltip_delays: Option<TooltipDelays>,
pub initial_focus: OverlayInitialFocus,
pub activate_on_trigger: bool,
pub width_policy: OverlayWidthPolicy,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum OverlayWidthPolicy {
#[default]
Content,
MatchTrigger,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum OverlayInitialFocus {
#[default]
Panel,
First,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LayerPlacement {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Center,
Fill,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LayerNodeSpec {
pub id: OverlayId,
pub placement: LayerPlacement,
pub inset: f64,
pub priority: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TooltipDelays {
pub show_ms: u64,
pub hide_ms: u64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverlayDismissPolicy {
pub escape: bool,
pub outside: bool,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct NodeKey(ImmutableString);
impl NodeKey {
#[must_use]
pub fn new(value: impl Into<ImmutableString>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceLocation {
pub module: String,
pub line: u32,
pub column: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Span {
text: ImmutableString,
key: Option<ImmutableString>,
color: Option<crate::ColorValue>,
bold: bool,
italic: bool,
motions: Vec<MotionSource>,
}
impl Span {
#[must_use]
pub fn new(text: impl Into<ImmutableString>) -> Self {
Self {
text: text.into(),
key: None,
color: None,
bold: false,
italic: false,
motions: Vec::new(),
}
}
#[must_use]
pub fn color(mut self, color: crate::ColorValue) -> Self {
self.color = Some(color);
self
}
#[must_use]
pub fn with_key(mut self, key: impl Into<ImmutableString>) -> Self {
self.key = Some(key.into());
self
}
#[must_use]
pub fn motion(mut self, source: MotionSource) -> Self {
self.motions
.retain(|existing| existing.property() != source.property());
self.motions.push(source);
self
}
#[must_use]
pub const fn bold(mut self) -> Self {
self.bold = true;
self
}
#[must_use]
pub const fn italic(mut self) -> Self {
self.italic = true;
self
}
#[must_use]
pub fn text(&self) -> &str {
self.text.as_str()
}
#[must_use]
pub fn key(&self) -> Option<&str> {
self.key.as_deref()
}
#[must_use]
pub fn motions(&self) -> &[MotionSource] {
&self.motions
}
#[must_use]
pub const fn color_value(&self) -> Option<&crate::ColorValue> {
self.color.as_ref()
}
#[must_use]
pub const fn is_bold(&self) -> bool {
self.bold
}
#[must_use]
pub const fn is_italic(&self) -> bool {
self.italic
}
}
impl CustomType for Span {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("Span")
.with_fn("with_key", |span: &mut Self, key: ImmutableString| {
span.clone().with_key(key)
})
.with_fn("color", |span: &mut Self, color: crate::ColorValue| {
span.clone().color(color)
})
.with_fn("motion", |span: &mut Self, source: MotionSource| {
span.clone().motion(source)
})
.with_fn("bold", |span: &mut Self| span.clone().bold())
.with_fn("italic", |span: &mut Self| span.clone().italic());
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum UiNodeKind {
Text {
text: ImmutableString,
},
RichText {
text: ImmutableString,
spans: Vec<Span>,
},
Canvas {
scene: crate::CanvasScene,
},
Svg {
source: crate::InlineSvg,
},
Box {
children: Vec<UiNode>,
},
Fragment {
children: Vec<UiNode>,
},
Custom {
primitive: PrimitiveNode,
},
Image {
source: ImageSourceSpec,
},
DirectionalImage {
left_to_right: ImageSourceSpec,
right_to_left: ImageSourceSpec,
},
Overlay {
trigger: Box<UiNode>,
content: Box<UiNode>,
spec: OverlayNodeSpec,
},
Layer {
content: Box<UiNode>,
spec: LayerNodeSpec,
},
VirtualCollection {
spec: crate::VirtualCollectionNodeSpec,
},
ErrorBoundary {
child: Box<UiNode>,
fallback: Box<UiNode>,
},
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum UiNodeKindTag {
Text,
Canvas,
Svg,
Box,
Fragment,
Custom,
Image,
DirectionalImage,
Overlay,
Layer,
VirtualCollection,
ErrorBoundary,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UiNode {
kind: UiNodeKind,
key: Option<NodeKey>,
style: Rc<Style>,
part_styles: BTreeMap<String, Style>,
source: Option<SourceLocation>,
component_root: Option<ComponentInstancePath>,
attributes: BTreeMap<String, UiValue>,
handlers: BTreeMap<String, Vec<UiEventBinding>>,
handler_payloads: BTreeMap<String, UiValue>,
motions: Vec<MotionSource>,
exit_motions: Vec<MotionSource>,
progress_motions: Vec<crate::MotionProgressBinding>,
timelines: Vec<crate::MotionTimeline>,
signal_bindings: BTreeMap<crate::SignalProperty, crate::NativeSignal>,
table_layout: Option<Rc<crate::table_layout::TableLayout>>,
table_column: Option<usize>,
element_ref: Option<crate::ElementRef>,
presentation: Vec<NodePresentationMutation>,
component_snapshot: Option<ComponentOwnedSnapshotRef>,
}
fn default_node_style() -> Rc<Style> {
thread_local! {
static STYLE: Rc<Style> = Rc::new(Style::new());
}
STYLE.with(Rc::clone)
}
#[derive(Clone, Default)]
pub(crate) struct ComponentOwnedSnapshot(Rc<RefCell<Option<Rc<UiNode>>>>);
#[derive(Clone, Default)]
pub(crate) struct ComponentOwnedSnapshotRef(Weak<RefCell<Option<Rc<UiNode>>>>);
impl ComponentOwnedSnapshot {
pub(crate) fn reference(&self) -> ComponentOwnedSnapshotRef {
ComponentOwnedSnapshotRef(Rc::downgrade(&self.0))
}
pub(crate) fn current(&self) -> Option<Rc<UiNode>> {
self.0.borrow().clone()
}
pub(crate) fn restore(&self, snapshot: Option<Rc<UiNode>>) {
*self.0.borrow_mut() = snapshot;
}
pub(crate) fn update_if_active(&self, node: &UiNode) {
let mut snapshot = self.0.borrow_mut();
if snapshot.is_some() {
*snapshot = Some(Rc::new(node.clone()));
}
}
pub(crate) fn replace_if_active(&self, node: Rc<UiNode>) {
let mut snapshot = self.0.borrow_mut();
if snapshot.is_some() {
*snapshot = Some(node);
}
}
}
impl fmt::Debug for ComponentOwnedSnapshot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ComponentOwnedSnapshot")
.field("active", &self.0.borrow().is_some())
.finish()
}
}
impl ComponentOwnedSnapshotRef {
fn current(&self) -> Option<Rc<UiNode>> {
self.0.upgrade()?.borrow().clone()
}
fn activate(&self, node: &UiNode) {
let Some(snapshot) = self.0.upgrade() else {
return;
};
let mut snapshot = snapshot.borrow_mut();
if snapshot.is_none() {
*snapshot = Some(Rc::new(node.clone()));
}
}
}
impl fmt::Debug for ComponentOwnedSnapshotRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ComponentOwnedSnapshotRef")
.field("live", &(self.0.strong_count() > 0))
.finish()
}
}
impl PartialEq for ComponentOwnedSnapshotRef {
fn eq(&self, _: &Self) -> bool {
true
}
}
#[derive(Clone, Debug, PartialEq)]
enum NodePresentationMutation {
Key(NodeKey),
Style(Rc<Style>),
PartStyles(Rc<BTreeMap<String, Style>>),
Signal(crate::SignalProperty, crate::NativeSignal),
TableTrack(Rc<crate::table_layout::TableLayout>),
TableColumn(usize),
ElementRef(crate::ElementRef),
Attribute(String, UiValue),
Handler(String, UiEventBinding),
HandlerPayload(String, UiValue),
Motion(MotionSource),
ExitMotion(MotionSource),
ProgressMotion(crate::MotionProgressBinding),
Timeline(Box<crate::MotionTimeline>),
MotionGroup(String),
}
impl NodePresentationMutation {
fn apply(&self, node: &mut UiNode) {
match self {
Self::Key(key) => node.key = Some(key.clone()),
Self::Style(style) => Rc::make_mut(&mut node.style).merge_in_place(style),
Self::PartStyles(styles) => node.part_styles.extend(styles.as_ref().clone()),
Self::Signal(property, signal) => {
node.signal_bindings.insert(*property, signal.clone());
}
Self::TableTrack(layout) => node.table_layout = Some(Rc::clone(layout)),
Self::TableColumn(index) => node.table_column = Some(*index),
Self::ElementRef(reference) => node.element_ref = Some(reference.clone()),
Self::Attribute(name, value) => {
node.attributes.insert(name.clone(), value.clone());
}
Self::Handler(event, binding) => {
node.handlers
.entry(event.clone())
.or_default()
.push(binding.clone());
}
Self::HandlerPayload(event, payload) => {
node.handler_payloads.insert(event.clone(), payload.clone());
}
Self::Motion(animation) => {
node.motions
.retain(|existing| existing.property() != animation.property());
node.motions.push(animation.clone());
}
Self::ExitMotion(motion) => {
node.exit_motions
.retain(|existing| existing.property() != motion.property());
node.exit_motions.push(motion.clone());
}
Self::ProgressMotion(binding) => {
node.progress_motions
.retain(|existing| existing.property() != binding.property());
node.progress_motions.push(binding.clone());
}
Self::Timeline(timeline) => {
node.timelines
.retain(|existing| existing.name != timeline.name);
node.timelines.push(timeline.as_ref().clone());
}
Self::MotionGroup(group) => apply_motion_group_contents(node, group),
}
}
fn bind_generation(&mut self, generation: ScriptGeneration) {
match self {
Self::Handler(_, binding) => {
if let Some(callback) = binding.handler_mut().as_script_mut() {
callback.bind_generation(generation);
}
}
Self::Timeline(timeline) => timeline.bind_generation(generation),
_ => {}
}
}
fn bind_component_scope(
&mut self,
component: &ComponentInstancePath,
incarnation: crate::ComponentIncarnation,
events: &BTreeMap<String, crate::EventSchema>,
native_context: Option<&crate::invocation::ScriptInvocationContext>,
) {
match self {
Self::Handler(_, binding) => {
if let Some(callback) = binding.handler_mut().as_script_mut() {
callback.bind_component_scope_if_unset(component, incarnation, events.clone());
if let Some(context) = native_context {
callback.bind_native_context_if_unset(context.clone());
}
}
}
Self::Timeline(timeline) => {
timeline.bind_component_scope(component, incarnation, events, native_context);
}
_ => {}
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ComponentSubtreeIndex {
addresses: BTreeMap<ComponentInstancePath, Vec<ComponentSubtreeStep>>,
}
#[derive(Clone, Debug)]
enum ComponentSubtreeStep {
Child(usize),
CustomNode(String),
CustomNodes(String, usize),
OverlayTrigger,
OverlayContent,
LayerContent,
VirtualItem(usize),
ErrorChild,
ErrorFallback,
}
impl ComponentSubtreeIndex {
pub(crate) fn contains(&self, component: &ComponentInstancePath) -> bool {
self.addresses.contains_key(component)
}
pub(crate) fn new(root: &UiNode) -> Self {
let mut index = Self {
addresses: BTreeMap::new(),
};
index.visit(root, &mut Vec::new());
index
}
pub(crate) fn get<'a>(
&self,
root: &'a UiNode,
component: &ComponentInstancePath,
) -> Option<&'a UiNode> {
let mut node = root;
for step in self.addresses.get(component)? {
node = match (step, &node.kind) {
(
ComponentSubtreeStep::Child(index),
UiNodeKind::Box { children } | UiNodeKind::Fragment { children },
) => children.get(*index)?,
(ComponentSubtreeStep::CustomNode(name), UiNodeKind::Custom { primitive }) => {
match primitive.props.get(name.as_str())? {
crate::PrimitiveValue::Node(node) => node,
_ => return None,
}
}
(
ComponentSubtreeStep::CustomNodes(name, index),
UiNodeKind::Custom { primitive },
) => match primitive.props.get(name.as_str())? {
crate::PrimitiveValue::Nodes(nodes) => nodes.get(*index)?,
_ => return None,
},
(ComponentSubtreeStep::OverlayTrigger, UiNodeKind::Overlay { trigger, .. }) => {
trigger
}
(ComponentSubtreeStep::OverlayContent, UiNodeKind::Overlay { content, .. }) => {
content
}
(ComponentSubtreeStep::LayerContent, UiNodeKind::Layer { content, .. }) => content,
(
ComponentSubtreeStep::VirtualItem(index),
UiNodeKind::VirtualCollection { spec },
) => spec.realized.get(index)?,
(ComponentSubtreeStep::ErrorChild, UiNodeKind::ErrorBoundary { child, .. }) => {
child
}
(
ComponentSubtreeStep::ErrorFallback,
UiNodeKind::ErrorBoundary { fallback, .. },
) => fallback,
_ => return None,
};
}
Some(node)
}
fn visit(&mut self, node: &UiNode, address: &mut Vec<ComponentSubtreeStep>) {
if let Some(component) = node.component_root() {
self.addresses.insert(component.clone(), address.clone());
}
match &node.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for (index, child) in children.iter().enumerate() {
address.push(ComponentSubtreeStep::Child(index));
self.visit(child, address);
address.pop();
}
}
UiNodeKind::Custom { primitive } => {
for (name, value) in primitive.props.iter() {
match value {
crate::PrimitiveValue::Node(node) => {
address.push(ComponentSubtreeStep::CustomNode(name.to_string()));
self.visit(node.as_ref(), address);
address.pop();
}
crate::PrimitiveValue::Nodes(nodes) => {
for (index, node) in nodes.iter().enumerate() {
address.push(ComponentSubtreeStep::CustomNodes(
name.to_string(),
index,
));
self.visit(node, address);
address.pop();
}
}
_ => {}
}
}
}
UiNodeKind::Overlay {
trigger, content, ..
} => {
address.push(ComponentSubtreeStep::OverlayTrigger);
self.visit(trigger, address);
address.pop();
address.push(ComponentSubtreeStep::OverlayContent);
self.visit(content, address);
address.pop();
}
UiNodeKind::Layer { content, .. } => {
address.push(ComponentSubtreeStep::LayerContent);
self.visit(content, address);
address.pop();
}
UiNodeKind::VirtualCollection { spec } => {
for (index, node) in &spec.realized {
address.push(ComponentSubtreeStep::VirtualItem(*index));
self.visit(node, address);
address.pop();
}
}
UiNodeKind::ErrorBoundary { child, fallback } => {
address.push(ComponentSubtreeStep::ErrorChild);
self.visit(child, address);
address.pop();
address.push(ComponentSubtreeStep::ErrorFallback);
self.visit(fallback, address);
address.pop();
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
}
}
}
impl UiNode {
#[must_use]
pub fn text(text: impl Into<ImmutableString>) -> Self {
Self {
kind: UiNodeKind::Text { text: text.into() },
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn rich_text(spans: Vec<Span>) -> Self {
let text = spans.iter().map(Span::text).collect::<String>().into();
Self {
kind: UiNodeKind::RichText { text, spans },
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn canvas(scene: crate::CanvasScene) -> Self {
let mut node = Self::text("");
node.kind = UiNodeKind::Canvas { scene };
node
}
pub fn svg(source: impl Into<String>) -> Result<Self, crate::InlineSvgError> {
let mut node = Self::text("");
node.kind = UiNodeKind::Svg {
source: crate::InlineSvg::new(source)?,
};
Ok(node)
}
#[must_use]
pub fn box_node(children: Vec<Self>) -> Self {
Self {
kind: UiNodeKind::Box { children },
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn fragment(children: Vec<Self>) -> Self {
let mut node = Self::box_node(children);
node.kind = match node.kind {
UiNodeKind::Box { children } => UiNodeKind::Fragment { children },
_ => unreachable!(),
};
node
}
#[must_use]
pub fn column(children: Vec<Self>) -> Self {
Self::box_node(children).with_style(&Style::new().flex_col())
}
#[must_use]
pub fn row(children: Vec<Self>) -> Self {
Self::box_node(children).with_style(&Style::new().flex_row())
}
#[must_use]
pub fn custom(primitive: PrimitiveNode) -> Self {
let key = primitive.key.as_ref().map(NodeKey::new);
Self {
kind: UiNodeKind::Custom { primitive },
key,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn error_boundary(child: Self, fallback: Self) -> Self {
Self {
kind: UiNodeKind::ErrorBoundary {
child: Box::new(child),
fallback: Box::new(fallback),
},
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn image(handle: OpaqueHandle) -> Self {
Self::image_source(ImageSourceSpec::Handle(handle))
}
#[must_use]
pub fn asset_image(asset: AssetId) -> Self {
Self::image_source(ImageSourceSpec::Asset(asset))
}
fn image_source(source: ImageSourceSpec) -> Self {
Self {
kind: UiNodeKind::Image { source },
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn directional_image(left_to_right: OpaqueHandle, right_to_left: OpaqueHandle) -> Self {
Self::directional_image_sources(
ImageSourceSpec::Handle(left_to_right),
ImageSourceSpec::Handle(right_to_left),
)
}
#[must_use]
pub fn directional_asset_image(left_to_right: AssetId, right_to_left: AssetId) -> Self {
Self::directional_image_sources(
ImageSourceSpec::Asset(left_to_right),
ImageSourceSpec::Asset(right_to_left),
)
}
fn directional_image_sources(
left_to_right: ImageSourceSpec,
right_to_left: ImageSourceSpec,
) -> Self {
Self {
kind: UiNodeKind::DirectionalImage {
left_to_right,
right_to_left,
},
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn overlay(trigger: Self, content: Self, spec: OverlayNodeSpec) -> Self {
Self {
kind: UiNodeKind::Overlay {
trigger: Box::new(trigger),
content: Box::new(content),
spec,
},
key: None,
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn layer(content: Self, spec: LayerNodeSpec) -> Self {
let key = spec.id.as_str().to_owned();
Self {
kind: UiNodeKind::Layer {
content: Box::new(content),
spec,
},
key: Some(NodeKey::new(key)),
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
#[must_use]
pub fn virtual_collection(spec: crate::VirtualCollectionNodeSpec) -> Self {
let key = spec.id.key.clone();
Self {
kind: UiNodeKind::VirtualCollection { spec },
key: Some(NodeKey::new(key)),
style: default_node_style(),
part_styles: BTreeMap::new(),
source: None,
component_root: None,
attributes: BTreeMap::new(),
handlers: BTreeMap::new(),
handler_payloads: BTreeMap::new(),
motions: Vec::new(),
exit_motions: Vec::new(),
progress_motions: Vec::new(),
timelines: Vec::new(),
signal_bindings: BTreeMap::new(),
table_layout: None,
table_column: None,
element_ref: None,
presentation: Vec::new(),
component_snapshot: None,
}
}
fn apply_presentation_mutation(&mut self, mutation: NodePresentationMutation) {
if self.component_root.is_some()
&& let Some(snapshot) = self.component_snapshot.clone()
{
snapshot.activate(self);
}
mutation.apply(self);
if self.component_root.is_some() {
self.presentation.push(mutation);
}
}
fn restore_presentation(&mut self, presentation: Vec<NodePresentationMutation>) {
for mutation in &presentation {
mutation.apply(self);
}
self.presentation.extend(presentation);
}
#[must_use]
pub fn with_key(mut self, key: impl Into<ImmutableString>) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Key(NodeKey::new(key)));
self
}
#[must_use]
pub fn with_style(mut self, style: &Style) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Style(Rc::new(style.clone())));
self
}
pub fn with_signal_binding(
mut self,
property: crate::SignalProperty,
signal: crate::NativeSignal,
) -> Result<Self, crate::SignalError> {
let expected = property.signal_kind();
let actual = signal.id().kind();
if expected != actual {
return Err(crate::SignalError::InvalidBinding {
property,
expected,
actual,
});
}
self.apply_presentation_mutation(NodePresentationMutation::Signal(property, signal));
Ok(self)
}
#[must_use]
pub fn with_element_ref(mut self, reference: crate::ElementRef) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::ElementRef(reference));
self
}
#[must_use]
pub fn with_scrollbars(mut self, spec: crate::ScrollbarSpec) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"scrollbar_horizontal".to_owned(),
UiValue::String(spec.horizontal().as_str().to_owned()),
));
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"scrollbar_vertical".to_owned(),
UiValue::String(spec.vertical().as_str().to_owned()),
));
self
}
#[must_use]
pub fn with_part_styles(mut self, styles: BTreeMap<String, Style>) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::PartStyles(Rc::new(styles)));
self
}
pub(crate) fn with_owned_part_styles(mut self, styles: BTreeMap<String, Style>) -> Self {
self.part_styles.extend(styles);
self
}
#[must_use]
pub fn with_part_style(mut self, part: impl Into<String>, style: Style) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::PartStyles(Rc::new(
BTreeMap::from([(part.into(), style)]),
)));
self
}
#[must_use]
pub fn with_source(mut self, source: SourceLocation) -> Self {
self.source = Some(source);
self
}
#[cfg(test)]
pub(crate) fn with_component_root(mut self, component: ComponentInstancePath) -> Self {
self.component_root = Some(component);
self.presentation.clear();
self.component_snapshot = None;
self
}
pub(crate) fn with_component_root_snapshot(
mut self,
component: ComponentInstancePath,
snapshot: ComponentOwnedSnapshotRef,
) -> Self {
self.component_root = Some(component);
self.presentation.clear();
self.component_snapshot = Some(snapshot);
self
}
#[must_use]
pub fn component_root(&self) -> Option<&ComponentInstancePath> {
self.component_root.as_ref()
}
pub(crate) fn replace_component_subtree(
&mut self,
component: &ComponentInstancePath,
replacement: Self,
) -> bool {
if self.component_root.as_ref() == Some(component) {
let presentation = std::mem::take(&mut self.presentation);
*self = replacement;
self.restore_presentation(presentation);
return true;
}
match &mut self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
replace_in_nodes(children.iter_mut(), component, &replacement)
}
UiNodeKind::Custom { primitive } => {
for (_, value) in primitive.props.iter_mut() {
let replaced = match value {
crate::PrimitiveValue::Node(node) => {
node.replace_component_subtree(component, replacement.clone())
}
crate::PrimitiveValue::Nodes(nodes) => {
replace_in_nodes(nodes.iter_mut(), component, &replacement)
}
_ => false,
};
if replaced {
return true;
}
}
false
}
UiNodeKind::Overlay {
trigger, content, ..
}
| UiNodeKind::ErrorBoundary {
child: trigger,
fallback: content,
} => {
trigger.replace_component_subtree(component, replacement.clone())
|| content.replace_component_subtree(component, replacement)
}
UiNodeKind::Layer { content, .. } => {
content.replace_component_subtree(component, replacement)
}
UiNodeKind::VirtualCollection { spec } => {
replace_in_nodes(spec.realized.values_mut(), component, &replacement)
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => false,
}
}
pub(crate) fn activate_component_snapshots(&mut self) {
if self.component_root.is_some()
&& let Some(snapshot) = self.component_snapshot.clone()
{
snapshot.activate(self);
}
self.visit_component_children_mut(Self::activate_component_snapshots);
}
pub(crate) fn hydrate_component_subtrees(&mut self) {
if let Some(current) = self
.component_snapshot
.as_ref()
.and_then(ComponentOwnedSnapshotRef::current)
{
let presentation = std::mem::take(&mut self.presentation);
*self = current.as_ref().clone();
self.restore_presentation(presentation);
}
self.visit_component_children_mut(Self::hydrate_component_subtrees);
}
fn visit_component_children_mut(&mut self, mut visit: impl FnMut(&mut Self)) {
match &mut self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for child in children {
visit(child);
}
}
UiNodeKind::Custom { primitive } => {
for (_, value) in primitive.props.iter_mut() {
match value {
crate::PrimitiveValue::Node(node) => {
visit(node);
}
crate::PrimitiveValue::Nodes(nodes) => {
for node in nodes {
visit(node);
}
}
crate::PrimitiveValue::Data(_)
| crate::PrimitiveValue::Callback(_)
| crate::PrimitiveValue::Style(_)
| crate::PrimitiveValue::Length(_)
| crate::PrimitiveValue::Asset(_)
| crate::PrimitiveValue::Signal(_)
| crate::PrimitiveValue::Ref(_)
| crate::PrimitiveValue::Document(_) => {}
#[cfg(feature = "charts")]
crate::PrimitiveValue::ChartData(_) => {}
}
}
}
UiNodeKind::Overlay {
trigger, content, ..
} => {
visit(trigger);
visit(content);
}
UiNodeKind::Layer { content, .. } => {
visit(content);
}
UiNodeKind::VirtualCollection { spec } => {
for item in spec.realized.values_mut() {
visit(item);
}
}
UiNodeKind::ErrorBoundary { child, fallback } => {
visit(child);
visit(fallback);
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
}
}
pub(crate) fn virtual_collection_items(
&self,
id: &crate::VirtualCollectionId,
) -> Option<&BTreeMap<usize, UiNode>> {
self.virtual_collection_spec(id).map(|spec| &spec.realized)
}
pub(crate) fn virtual_read_contributions(
&self,
) -> std::collections::BTreeSet<crate::read_dependency::ReadContribution> {
let mut active = std::collections::BTreeSet::new();
self.collect_virtual_read_contributions(&mut active);
active
}
fn collect_virtual_read_contributions(
&self,
active: &mut std::collections::BTreeSet<crate::read_dependency::ReadContribution>,
) {
if let UiNodeKind::VirtualCollection { spec } = &self.kind {
for index in spec.realized.keys() {
if let Some(key) = spec.data.key(*index) {
active.insert(crate::read_dependency::ReadContribution::VirtualItem {
collection: spec.id.clone(),
key: key.to_owned(),
});
}
}
}
for (_, children) in self.retained_child_groups() {
for child in children {
child.collect_virtual_read_contributions(active);
}
}
}
pub(crate) fn virtual_collection_spec(
&self,
id: &crate::VirtualCollectionId,
) -> Option<&crate::VirtualCollectionNodeSpec> {
match &self.kind {
UiNodeKind::VirtualCollection { spec } if &spec.id == id => Some(spec),
UiNodeKind::VirtualCollection { spec } => spec
.realized
.values()
.find_map(|child| child.virtual_collection_spec(id)),
UiNodeKind::Custom { primitive } => {
primitive.props.iter().find_map(|(_, value)| match value {
crate::PrimitiveValue::Node(node) => node.virtual_collection_spec(id),
crate::PrimitiveValue::Nodes(nodes) => nodes
.iter()
.find_map(|node| node.virtual_collection_spec(id)),
_ => None,
})
}
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => children
.iter()
.find_map(|child| child.virtual_collection_spec(id)),
UiNodeKind::Overlay {
trigger, content, ..
}
| UiNodeKind::ErrorBoundary {
child: trigger,
fallback: content,
} => trigger
.virtual_collection_spec(id)
.or_else(|| content.virtual_collection_spec(id)),
UiNodeKind::Layer { content, .. } => content.virtual_collection_spec(id),
_ => None,
}
}
pub(crate) fn replace_virtual_collection_items(
&mut self,
id: &crate::VirtualCollectionId,
items: BTreeMap<usize, UiNode>,
) -> bool {
self.replace_virtual_collection_items_in(id, &mut Some(items))
}
fn replace_virtual_collection_items_in(
&mut self,
id: &crate::VirtualCollectionId,
items: &mut Option<BTreeMap<usize, UiNode>>,
) -> bool {
match &mut self.kind {
UiNodeKind::VirtualCollection { spec } if &spec.id == id => {
if let Some(items) = items.take() {
spec.realized = items;
}
true
}
UiNodeKind::VirtualCollection { spec } => spec
.realized
.values_mut()
.any(|child| child.replace_virtual_collection_items_in(id, items)),
UiNodeKind::Custom { primitive } => {
primitive.props.iter_mut().any(|(_, value)| match value {
crate::PrimitiveValue::Node(node) => {
node.replace_virtual_collection_items_in(id, items)
}
crate::PrimitiveValue::Nodes(nodes) => nodes
.iter_mut()
.any(|node| node.replace_virtual_collection_items_in(id, items)),
_ => false,
})
}
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => children
.iter_mut()
.any(|child| child.replace_virtual_collection_items_in(id, items)),
UiNodeKind::Overlay {
trigger, content, ..
}
| UiNodeKind::ErrorBoundary {
child: trigger,
fallback: content,
} => {
trigger.replace_virtual_collection_items_in(id, items)
|| content.replace_virtual_collection_items_in(id, items)
}
UiNodeKind::Layer { content, .. } => {
content.replace_virtual_collection_items_in(id, items)
}
_ => false,
}
}
#[must_use]
pub fn with_attribute(mut self, name: impl Into<String>, value: UiValue) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(name.into(), value));
self
}
#[must_use]
pub fn with_handler(
mut self,
event: impl Into<String>,
handler: impl Into<UiEventHandler>,
) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Handler(
event.into(),
UiEventBinding::new(crate::EventPhase::Target, handler),
));
self
}
#[must_use]
pub fn with_handler_phase(
mut self,
event: impl Into<String>,
phase: crate::EventPhase,
handler: impl Into<UiEventHandler>,
) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Handler(
event.into(),
UiEventBinding::new(phase, handler),
));
self
}
#[must_use]
pub fn with_host_handler(self, event: impl Into<String>, callback: HostCallback) -> Self {
self.with_handler(event, UiEventHandler::Host(callback))
}
#[must_use]
pub fn with_handler_payload(mut self, event: impl Into<String>, payload: UiValue) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::HandlerPayload(
event.into(),
payload,
));
self
}
#[must_use]
pub fn with_motion(mut self, animation: MotionSource) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Motion(animation));
self
}
#[must_use]
pub fn with_exit_motion(mut self, motion: MotionSource) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::ExitMotion(motion));
self
}
#[must_use]
pub fn with_progress_motion(mut self, binding: crate::MotionProgressBinding) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::ProgressMotion(binding));
self
}
#[must_use]
pub fn with_motion_replay_key(mut self, key: impl Into<String>) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"motion_replay_key".to_owned(),
UiValue::String(key.into()),
));
self
}
#[must_use]
pub fn with_motion_particle_count(mut self, count: usize) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"motion_particle_count".to_owned(),
UiValue::Integer(INT::try_from(count).unwrap_or(INT::MAX)),
));
self
}
#[must_use]
pub fn with_timeline(mut self, timeline: crate::MotionTimeline) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Timeline(Box::new(timeline)));
self
}
#[must_use]
pub fn with_layout_motion(mut self, duration_ms: u64, easing: crate::MotionEasing) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"layout_motion_duration_ms".to_owned(),
UiValue::Integer(INT::try_from(duration_ms).unwrap_or(INT::MAX)),
));
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"layout_motion_easing".to_owned(),
UiValue::String(easing.as_str().to_owned()),
));
self
}
#[must_use]
pub fn with_shared_layout(mut self, group: impl Into<String>, id: impl Into<String>) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"shared_layout_group".to_owned(),
UiValue::String(group.into()),
));
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"shared_layout_id".to_owned(),
UiValue::String(id.into()),
));
self
}
#[must_use]
pub fn with_shared_layout_id(mut self, id: impl Into<String>) -> Self {
self.apply_presentation_mutation(NodePresentationMutation::Attribute(
"shared_layout_id".to_owned(),
UiValue::String(id.into()),
));
self
}
#[must_use]
pub fn handlers(&self) -> &BTreeMap<String, Vec<UiEventBinding>> {
&self.handlers
}
#[must_use]
pub fn handler(&self, event: &str) -> Option<&UiEventHandler> {
self.handlers
.get(event)?
.iter()
.find(|binding| binding.phase() == crate::EventPhase::Target)
.map(UiEventBinding::handler)
}
#[must_use]
pub fn event_handlers(&self, event: &str) -> &[UiEventBinding] {
self.handlers.get(event).map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn handler_payload(&self, event: &str) -> Option<&UiValue> {
self.handler_payloads.get(event)
}
#[must_use]
pub(crate) const fn handler_payloads(&self) -> &BTreeMap<String, UiValue> {
&self.handler_payloads
}
#[must_use]
pub fn motions(&self) -> &[MotionSource] {
&self.motions
}
#[must_use]
pub fn exit_motions(&self) -> &[MotionSource] {
&self.exit_motions
}
#[must_use]
pub fn progress_motions(&self) -> &[crate::MotionProgressBinding] {
&self.progress_motions
}
pub(crate) fn motion_ghost(&self) -> Option<Self> {
self.motion_ghost_node(true)
}
fn motion_ghost_node(&self, root: bool) -> Option<Self> {
let mut ghost = self.clone();
ghost.handlers.clear();
ghost.handler_payloads.clear();
ghost.signal_bindings.clear();
ghost.element_ref = None;
ghost.component_root = None;
ghost.component_snapshot = None;
ghost.presentation.clear();
ghost.motions.clear();
ghost.exit_motions.clear();
ghost.progress_motions.clear();
ghost.timelines.clear();
ghost.attributes.clear();
ghost
.attributes
.insert("motion_ghost".to_owned(), UiValue::Bool(true));
let style = &mut Rc::make_mut(&mut ghost.style).base;
style.hit_test = None;
style.cursor = None;
if root {
style.margin = crate::LayoutEdgeLengths::default();
style.position = None;
style.top = None;
style.right = None;
style.bottom = None;
style.left = None;
style.inset_start = None;
style.inset_end = None;
style.translate_x = None;
style.translate_y = None;
style.align_self = None;
style.flex_grow = None;
style.flex_grow_weight = None;
style.flex_shrink = None;
style.flex_basis = None;
}
match &mut ghost.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
*children = children
.iter()
.map(|child| child.motion_ghost_node(false))
.collect::<Option<Vec<_>>>()?;
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
UiNodeKind::Custom { .. }
| UiNodeKind::Overlay { .. }
| UiNodeKind::Layer { .. }
| UiNodeKind::VirtualCollection { .. }
| UiNodeKind::ErrorBoundary { .. } => return None,
}
Some(ghost)
}
#[must_use]
pub fn timelines(&self) -> &[crate::MotionTimeline] {
&self.timelines
}
pub(crate) fn bind_generation(&mut self, generation: ScriptGeneration) {
for bindings in self.handlers.values_mut() {
for binding in bindings {
if let Some(callback) = binding.handler_mut().as_script_mut() {
callback.bind_generation(generation);
}
}
}
for timeline in &mut self.timelines {
timeline.bind_generation(generation);
}
for mutation in &mut self.presentation {
mutation.bind_generation(generation);
}
match &mut self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for child in children {
child.bind_generation(generation);
}
}
UiNodeKind::ErrorBoundary { child, fallback } => {
child.bind_generation(generation);
fallback.bind_generation(generation);
}
UiNodeKind::Overlay {
trigger, content, ..
} => {
trigger.bind_generation(generation);
content.bind_generation(generation);
}
UiNodeKind::Layer { content, .. } => content.bind_generation(generation),
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Custom { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
UiNodeKind::VirtualCollection { spec } => {
for item in spec.realized.values_mut() {
item.bind_generation(generation);
}
}
}
}
pub(crate) fn bind_component_scope(
&mut self,
component: &crate::ComponentInstancePath,
incarnation: crate::ComponentIncarnation,
events: &BTreeMap<String, crate::EventSchema>,
native_context: Option<&crate::invocation::ScriptInvocationContext>,
) {
for bindings in self.handlers.values_mut() {
for binding in bindings {
if let Some(callback) = binding.handler_mut().as_script_mut() {
callback.bind_component_scope_if_unset(component, incarnation, events.clone());
if let Some(context) = native_context {
callback.bind_native_context_if_unset(context.clone());
}
}
}
}
for timeline in &mut self.timelines {
timeline.bind_component_scope(component, incarnation, events, native_context);
}
for mutation in &mut self.presentation {
mutation.bind_component_scope(component, incarnation, events, native_context);
}
match &mut self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for child in children {
child.bind_component_scope(component, incarnation, events, native_context);
}
}
UiNodeKind::Custom { primitive } => {
primitive.props.bind_component_scope(
component,
incarnation,
events,
native_context,
);
}
UiNodeKind::ErrorBoundary { child, fallback } => {
child.bind_component_scope(component, incarnation, events, native_context);
fallback.bind_component_scope(component, incarnation, events, native_context);
}
UiNodeKind::Overlay {
trigger, content, ..
} => {
trigger.bind_component_scope(component, incarnation, events, native_context);
content.bind_component_scope(component, incarnation, events, native_context);
}
UiNodeKind::Layer { content, .. } => {
content.bind_component_scope(component, incarnation, events, native_context);
}
UiNodeKind::VirtualCollection { spec } => {
for item in spec.realized.values_mut() {
item.bind_component_scope(component, incarnation, events, native_context);
}
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
}
}
#[must_use]
pub fn kind(&self) -> &UiNodeKind {
&self.kind
}
#[must_use]
pub const fn kind_tag(&self) -> UiNodeKindTag {
match self.kind {
UiNodeKind::Text { .. } | UiNodeKind::RichText { .. } => UiNodeKindTag::Text,
UiNodeKind::Canvas { .. } => UiNodeKindTag::Canvas,
UiNodeKind::Svg { .. } => UiNodeKindTag::Svg,
UiNodeKind::Box { .. } => UiNodeKindTag::Box,
UiNodeKind::Fragment { .. } => UiNodeKindTag::Fragment,
UiNodeKind::Custom { .. } => UiNodeKindTag::Custom,
UiNodeKind::Image { .. } => UiNodeKindTag::Image,
UiNodeKind::DirectionalImage { .. } => UiNodeKindTag::DirectionalImage,
UiNodeKind::Overlay { .. } => UiNodeKindTag::Overlay,
UiNodeKind::Layer { .. } => UiNodeKindTag::Layer,
UiNodeKind::VirtualCollection { .. } => UiNodeKindTag::VirtualCollection,
UiNodeKind::ErrorBoundary { .. } => UiNodeKindTag::ErrorBoundary,
}
}
pub(crate) fn retained_child_groups(&self) -> Vec<(String, Vec<&Self>)> {
match &self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
vec![("children".to_owned(), children.iter().collect())]
}
UiNodeKind::Custom { primitive } => primitive
.props
.iter()
.filter_map(|(name, value)| match value {
crate::PrimitiveValue::Node(node) => {
Some((format!("prop:{name}"), vec![node.as_ref()]))
}
crate::PrimitiveValue::Nodes(nodes) => {
Some((format!("prop:{name}"), nodes.iter().collect::<Vec<_>>()))
}
crate::PrimitiveValue::Data(_)
| crate::PrimitiveValue::Callback(_)
| crate::PrimitiveValue::Style(_)
| crate::PrimitiveValue::Length(_)
| crate::PrimitiveValue::Asset(_)
| crate::PrimitiveValue::Signal(_)
| crate::PrimitiveValue::Ref(_)
| crate::PrimitiveValue::Document(_) => None,
#[cfg(feature = "charts")]
crate::PrimitiveValue::ChartData(_) => None,
})
.collect(),
UiNodeKind::Overlay {
trigger, content, ..
} => vec![
("trigger".to_owned(), vec![trigger.as_ref()]),
("content".to_owned(), vec![content.as_ref()]),
],
UiNodeKind::Layer { content, .. } => {
vec![("content".to_owned(), vec![content.as_ref()])]
}
UiNodeKind::VirtualCollection { spec } => {
vec![("items".to_owned(), spec.realized.values().collect())]
}
UiNodeKind::ErrorBoundary { child, fallback } => vec![
("child".to_owned(), vec![child.as_ref()]),
("fallback".to_owned(), vec![fallback.as_ref()]),
],
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => Vec::new(),
}
}
#[must_use]
pub fn key(&self) -> Option<&NodeKey> {
self.key.as_ref()
}
#[must_use]
pub fn style(&self) -> &Style {
&self.style
}
#[must_use]
pub fn part_style(&self, part: &str) -> Option<&Style> {
self.part_styles.get(part)
}
pub fn part_styles(&self) -> impl Iterator<Item = (&str, &Style)> {
self.part_styles
.iter()
.map(|(name, style)| (name.as_str(), style))
}
#[must_use]
pub fn source(&self) -> Option<&SourceLocation> {
self.source.as_ref()
}
#[must_use]
pub fn attributes(&self) -> &BTreeMap<String, UiValue> {
&self.attributes
}
pub fn signal_bindings(
&self,
) -> impl ExactSizeIterator<Item = (crate::SignalProperty, &crate::NativeSignal)> {
self.signal_bindings
.iter()
.map(|(property, signal)| (*property, signal))
}
#[must_use]
pub const fn element_ref(&self) -> Option<&crate::ElementRef> {
self.element_ref.as_ref()
}
pub(crate) fn table_layout(&self) -> Option<&crate::table_layout::TableLayout> {
self.table_layout.as_deref()
}
pub(crate) const fn table_column(&self) -> Option<usize> {
self.table_column
}
pub(crate) fn resolve_table_layout(
&mut self,
plan: &crate::table_layout::ResolvedColumns,
border: f64,
) {
self.table_layout = Some(Rc::new(crate::table_layout::TableLayout::Resolved {
extent: plan.extent + border,
}));
if let UiNodeKind::Box { children } = &mut self.kind {
for child in children {
child.resolve_table_columns(plan);
let mut width = Style::new();
width.base.width = Some(crate::Length::Pixels(plan.extent).into());
width.base.flex_shrink = Some(false);
Rc::make_mut(&mut child.style).merge_in_place(&width);
}
}
}
fn resolve_table_columns(&mut self, plan: &crate::table_layout::ResolvedColumns) {
if self.table_layout.is_some() {
return;
}
if let Some(width) = self.table_column.and_then(|index| plan.widths.get(index)) {
let style = Rc::make_mut(&mut self.style);
style.base.width = Some(crate::Length::Pixels(*width).into());
style.base.min_width = style.base.width;
style.base.max_width = style.base.width;
style.base.flex_basis = None;
style.base.flex_grow = Some(false);
style.base.flex_grow_weight = None;
style.base.flex_shrink = Some(false);
self.signal_bindings.remove(&crate::SignalProperty::Width);
self.signal_bindings
.remove(&crate::SignalProperty::WidthOverride);
}
match &mut self.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for child in children {
child.resolve_table_columns(plan);
}
}
UiNodeKind::VirtualCollection { spec } => {
for child in spec.realized.values_mut() {
child.resolve_table_columns(plan);
}
}
_ => {}
}
}
}
fn replace_in_nodes<'a>(
nodes: impl IntoIterator<Item = &'a mut UiNode>,
component: &ComponentInstancePath,
replacement: &UiNode,
) -> bool {
for node in nodes {
if node.replace_component_subtree(component, replacement.clone()) {
return true;
}
}
false
}
impl CustomType for UiNode {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("UiNode")
.with_fn("with_key", |node: &mut Self, key: ImmutableString| {
node.clone().with_key(key)
})
.with_fn("with_style", |node: &mut Self, style: Style| {
node.clone().with_style(&style)
})
.with_fn(
"with_table_track",
|node: &mut Self, columns: Array, signals: Array| {
if !matches!(node.kind(), UiNodeKind::Box { .. }) {
return Err(crate::table_layout::runtime_error(
"Table track requires a Box container",
));
}
crate::table_layout::decode_columns(&columns, &signals).map(|columns| {
let mut result = node.clone();
result.apply_presentation_mutation(NodePresentationMutation::TableTrack(
Rc::new(crate::table_layout::TableLayout::Columns(columns)),
));
result
})
},
)
.with_fn(
"with_table_column",
|node: &mut Self, index: INT| -> Result<Self, Box<EvalAltResult>> {
let index = usize::try_from(index).map_err(|_| {
crate::table_layout::runtime_error(
"Table column index must be non-negative",
)
})?;
if index >= 256 {
return Err(crate::table_layout::runtime_error(
"Table column index must be below 256",
));
}
let mut result = node.clone();
result
.apply_presentation_mutation(NodePresentationMutation::TableColumn(index));
Ok(result)
},
)
.with_fn(
"bind_signal",
|node: &mut Self,
property: ImmutableString,
signal: crate::NativeSignal|
-> Result<Self, Box<EvalAltResult>> {
let property = crate::SignalProperty::parse(property.as_str())
.map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
node.clone()
.with_signal_binding(property, signal)
.map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))
},
)
.with_fn(
"bind_parent_signal",
|node: &mut Self,
context: crate::UiContext,
property: ImmutableString,
key: ImmutableString|
-> Result<Self, Box<EvalAltResult>> {
let property = crate::SignalProperty::parse(property.as_str())
.map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
if property.signal_kind() != crate::SignalKind::OptionalFloat {
return Err(Box::new(crate::signal::signal_runtime_error(
&"parent signal binding currently supports optional-float properties",
)));
}
let signal = context
.parent_optional_float_signal_ref(key.as_str())
.map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
node.clone()
.with_signal_binding(property, signal)
.map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))
},
)
.with_fn(
"with_ref",
|node: &mut Self, reference: crate::ElementRef| {
node.clone().with_element_ref(reference)
},
)
.with_fn(
"with_part_style",
|node: &mut Self, part: ImmutableString, style: Style| {
node.clone().with_part_style(part.to_string(), style)
},
);
register_value_event_methods(&mut builder);
register_raw_event_methods(&mut builder);
register_semantic_event_methods(&mut builder);
register_node_behavior_methods(&mut builder);
register_accessibility_methods(&mut builder);
}
}
#[allow(clippy::too_many_lines)]
fn register_node_behavior_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn("motion", |node: &mut UiNode, animation: MotionSource| {
node.clone().with_motion(animation)
})
.with_fn("enter_motion", |node: &mut UiNode, motion: MotionSource| {
node.clone().with_motion(motion)
})
.with_fn("exit_motion", |node: &mut UiNode, motion: MotionSource| {
node.clone().with_exit_motion(motion)
})
.with_fn(
"progress_motion",
|node: &mut UiNode, binding: crate::MotionProgressBinding| {
node.clone().with_progress_motion(binding)
},
)
.with_fn(
"motion_replay_key",
|node: &mut UiNode, key: ImmutableString| {
node.clone().with_motion_replay_key(key.to_string())
},
)
.with_fn(
"motion_particle_count",
|node: &mut UiNode, count: INT| -> Result<UiNode, Box<EvalAltResult>> {
let count = usize::try_from(count).map_err(|_| {
Box::new(EvalAltResult::ErrorRuntime(
"motion particle count must be non-negative".into(),
Position::NONE,
))
})?;
Ok(node.clone().with_motion_particle_count(count))
},
)
.with_fn(
"timeline",
|node: &mut UiNode, timeline: crate::MotionTimeline| {
node.clone().with_timeline(timeline)
},
)
.with_fn(
"layout_motion",
|node: &mut UiNode,
duration_ms: INT,
easing: ImmutableString|
-> Result<UiNode, Box<EvalAltResult>> {
let duration_ms = u64::try_from(duration_ms).map_err(|_| {
Box::new(EvalAltResult::ErrorRuntime(
"layout motion duration must be non-negative".into(),
Position::NONE,
))
})?;
let easing = crate::MotionEasing::parse(easing.as_str()).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node.clone().with_layout_motion(duration_ms, easing))
},
)
.with_fn(
"shared_layout",
|node: &mut UiNode, group: ImmutableString, id: ImmutableString| {
node.clone()
.with_shared_layout(group.to_string(), id.to_string())
},
)
.with_fn("shared_layout", |node: &mut UiNode, id: ImmutableString| {
node.clone().with_shared_layout_id(id.to_string())
})
.with_fn("disabled", |node: &mut UiNode, disabled: bool| {
node.clone()
.with_attribute("disabled", UiValue::Bool(disabled))
})
.with_fn("tab_stop", |node: &mut UiNode, tab_stop: bool| {
node.clone()
.with_attribute("tab_stop", UiValue::Bool(tab_stop))
})
.with_fn(
"selectable",
|node: &mut UiNode, selectable: bool| -> Result<UiNode, Box<EvalAltResult>> {
if selectable && !matches!(node.kind(), UiNodeKind::Text { .. }) {
return Err(Box::new(EvalAltResult::ErrorRuntime(
"selectable(true) is supported only on text() nodes".into(),
Position::NONE,
)));
}
Ok(node
.clone()
.with_attribute("selectable", UiValue::Bool(selectable)))
},
)
.with_fn(
"tab_index",
|node: &mut UiNode, index: INT| -> Result<UiNode, Box<EvalAltResult>> {
if !(-32_768..=32_767).contains(&index) {
return Err(Box::new(EvalAltResult::ErrorRuntime(
"tab index must be between -32768 and 32767".into(),
Position::NONE,
)));
}
Ok(node
.clone()
.with_attribute("tab_index", UiValue::Integer(index)))
},
)
.with_fn("tab_group", |node: &mut UiNode| {
node.clone()
.with_attribute("tab_group", UiValue::Bool(true))
})
.with_fn(
"scrollbars",
|node: &mut UiNode,
horizontal: ImmutableString,
vertical: ImmutableString|
-> Result<UiNode, Box<EvalAltResult>> {
let spec = crate::ScrollbarSpec::new(horizontal.as_str(), vertical.as_str())
.map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(error.into(), Position::NONE))
})?;
Ok(node.clone().with_scrollbars(spec))
},
)
.with_fn(
"on_key_value",
|call: NativeCallContext<'_>,
node: &mut UiNode,
key: ImmutableString,
callback: FnPtr,
payload: Dynamic|
-> Result<UiNode, Box<EvalAltResult>> {
let key = normalize_key_handler_name(key.as_str())?;
let payload = UiValue::from_dynamic(payload).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
let event = format!("key:{key}");
Ok(node
.clone()
.with_handler(event.clone(), retained_script_callback(&call, callback)?)
.with_handler_payload(event, payload))
},
);
}
fn register_value_event_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"on_click_value",
|call: NativeCallContext<'_>,
node: &mut UiNode,
callback: FnPtr,
payload: Dynamic|
-> Result<UiNode, Box<EvalAltResult>> {
let payload = dynamic_ui_value(payload)?;
Ok(node
.clone()
.with_handler("click", retained_script_callback(&call, callback)?)
.with_handler_payload("click", payload))
},
)
.with_fn(
"on_hover_value",
|call: NativeCallContext<'_>,
node: &mut UiNode,
callback: FnPtr,
payload: Dynamic|
-> Result<UiNode, Box<EvalAltResult>> {
let payload = dynamic_ui_value(payload)?;
Ok(node
.clone()
.with_handler("hover_change", retained_script_callback(&call, callback)?)
.with_handler_payload("hover_change", payload))
},
);
}
fn register_raw_event_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"on_click",
|call: NativeCallContext<'_>,
node: &mut UiNode,
callback: FnPtr|
-> Result<UiNode, Box<EvalAltResult>> {
Ok(node
.clone()
.with_handler("click", retained_script_callback(&call, callback)?))
},
)
.with_fn(
"on",
|call: NativeCallContext<'_>,
node: &mut UiNode,
event: ImmutableString,
callback: FnPtr|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
Ok(node
.clone()
.with_handler(event, retained_script_callback(&call, callback)?))
},
)
.with_fn(
"on_capture",
|call: NativeCallContext<'_>,
node: &mut UiNode,
event: ImmutableString,
callback: FnPtr|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
Ok(node.clone().with_handler_phase(
event,
crate::EventPhase::Capture,
retained_script_callback(&call, callback)?,
))
},
)
.with_fn(
"on_bubble",
|call: NativeCallContext<'_>,
node: &mut UiNode,
event: ImmutableString,
callback: FnPtr|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
Ok(node.clone().with_handler_phase(
event,
crate::EventPhase::Bubble,
retained_script_callback(&call, callback)?,
))
},
);
register_native_event_methods(builder);
}
fn register_native_event_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"on_click",
|node: &mut UiNode,
handler: crate::NativeHandlerRef|
-> Result<UiNode, Box<EvalAltResult>> {
handler.validate_event("click").map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node.clone().with_handler("click", handler))
},
)
.with_fn(
"on_click_value",
|node: &mut UiNode,
handler: crate::NativeHandlerRef,
payload: Dynamic|
-> Result<UiNode, Box<EvalAltResult>> {
handler.validate_event("click").map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node
.clone()
.with_handler("click", handler)
.with_handler_payload("click", dynamic_ui_value(payload)?))
},
)
.with_fn(
"on",
|node: &mut UiNode,
event: ImmutableString,
handler: crate::NativeHandlerRef|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
handler.validate_event(event.as_str()).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node.clone().with_handler(event, handler))
},
)
.with_fn(
"on_capture",
|node: &mut UiNode,
event: ImmutableString,
handler: crate::NativeHandlerRef|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
handler.validate_event(event.as_str()).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node
.clone()
.with_handler_phase(event, crate::EventPhase::Capture, handler))
},
)
.with_fn(
"on_bubble",
|node: &mut UiNode,
event: ImmutableString,
handler: crate::NativeHandlerRef|
-> Result<UiNode, Box<EvalAltResult>> {
let event = normalize_node_event_name(event.as_str())?;
handler.validate_event(event.as_str()).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
Ok(node
.clone()
.with_handler_phase(event, crate::EventPhase::Bubble, handler))
},
);
}
fn normalize_node_event_name(event: &str) -> Result<String, Box<EvalAltResult>> {
if let Some(key) = event.strip_prefix("key:") {
return normalize_key_handler_name(key).map(|key| format!("key:{key}"));
}
if (1..=64).contains(&event.len())
&& event.chars().all(|character| {
character.is_ascii_lowercase()
|| character.is_ascii_digit()
|| matches!(character, '_' | ':')
})
{
Ok(event.to_owned())
} else {
Err(Box::new(EvalAltResult::ErrorRuntime(
format!("event `{event}` must be a 1-64 character snake_case or namespaced name")
.into(),
Position::NONE,
)))
}
}
fn normalize_key_handler_name(key: &str) -> Result<String, Box<EvalAltResult>> {
let named = key
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
let punctuation =
key.len() == 1 && key.as_bytes()[0].is_ascii_punctuation() && key.as_bytes()[0] != b':';
if (1..=60).contains(&key.len()) && (named || punctuation) {
Ok(key.to_ascii_lowercase())
} else {
Err(Box::new(EvalAltResult::ErrorRuntime(
"key handler name must be 1-60 ASCII letters, digits or `_`, or one ASCII punctuation character other than `:`; whitespace and chords are not accepted"
.into(),
Position::NONE,
)))
}
}
fn register_semantic_event_methods(builder: &mut TypeBuilder<UiNode>) {
for (method, event) in [
("on_hover_change", "hover_change"),
("on_open_change", "open_change"),
("on_change", "change"),
] {
let event = event.to_owned();
builder.with_fn(
method,
move |call: NativeCallContext<'_>,
node: &mut UiNode,
callback: FnPtr|
-> Result<UiNode, Box<EvalAltResult>> {
Ok(node
.clone()
.with_handler(event.clone(), retained_script_callback(&call, callback)?))
},
);
}
}
fn retained_script_callback(
call: &NativeCallContext<'_>,
callback: FnPtr,
) -> Result<ScriptCallback, Box<EvalAltResult>> {
let mut callback = ScriptCallback::try_from_fn_ptr(callback, ScriptGeneration::default())
.map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})?;
callback.bind_native_context_if_unset(
crate::invocation::ScriptInvocationContext::capture_retained(call),
);
Ok(callback)
}
fn register_accessibility_methods(builder: &mut TypeBuilder<UiNode>) {
register_accessibility_state_methods(builder);
register_accessibility_reference_methods(builder);
}
fn register_accessibility_state_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"accessibility_role",
|node: &mut UiNode, role: ImmutableString| {
node.clone()
.with_attribute("role", UiValue::String(role.to_string()))
},
)
.with_fn(
"accessibility_label",
|node: &mut UiNode, label: ImmutableString| {
node.clone()
.with_attribute("label", UiValue::String(label.to_string()))
},
)
.with_fn(
"accessibility_checked",
|node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
let value = dynamic_ui_value(value)?;
Ok(node.clone().with_attribute("checked", value))
},
)
.with_fn(
"accessibility_pressed",
|node: &mut UiNode, pressed: bool| {
node.clone()
.with_attribute("pressed", UiValue::Bool(pressed))
},
)
.with_fn(
"accessibility_selected",
|node: &mut UiNode, selected: bool| {
node.clone()
.with_attribute("selected", UiValue::Bool(selected))
},
)
.with_fn(
"accessibility_expanded",
|node: &mut UiNode, expanded: bool| {
node.clone()
.with_attribute("expanded", UiValue::Bool(expanded))
},
)
.with_fn(
"accessibility_orientation",
|node: &mut UiNode,
orientation: ImmutableString|
-> Result<UiNode, Box<EvalAltResult>> {
if !matches!(orientation.as_str(), "horizontal" | "vertical") {
return Err(Box::new(EvalAltResult::ErrorRuntime(
"accessibility_orientation must be horizontal or vertical".into(),
Position::NONE,
)));
}
Ok(node
.clone()
.with_attribute("orientation", UiValue::String(orientation.to_string())))
},
);
register_accessibility_value_methods(builder);
register_accessibility_collection_methods(builder);
}
fn register_accessibility_value_methods(builder: &mut TypeBuilder<UiNode>) {
register_accessibility_range_methods(builder);
builder
.with_fn(
"accessibility_value",
|node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
let value = dynamic_ui_value(value)?;
Ok(node.clone().with_attribute("value", value))
},
)
.with_fn(
"accessibility_placeholder",
|node: &mut UiNode, placeholder: ImmutableString| {
node.clone()
.with_attribute("placeholder", UiValue::String(placeholder.to_string()))
},
)
.with_fn(
"accessibility_key_shortcuts",
|node: &mut UiNode, shortcuts: ImmutableString| {
node.clone()
.with_attribute("key_shortcuts", UiValue::String(shortcuts.to_string()))
},
)
.with_fn(
"accessibility_current",
|node: &mut UiNode, value: ImmutableString| -> Result<UiNode, Box<EvalAltResult>> {
if !matches!(
value.as_str(),
"page" | "step" | "location" | "date" | "time" | "true"
) {
return Err(Box::new(EvalAltResult::ErrorRuntime(
"accessibility_current must be page, step, location, date, time, or true"
.into(),
Position::NONE,
)));
}
Ok(node
.clone()
.with_attribute("current", UiValue::String(value.to_string())))
},
)
.with_fn(
"accessibility_invalid",
|node: &mut UiNode, invalid: bool| {
node.clone()
.with_attribute("invalid", UiValue::Bool(invalid))
},
)
.with_fn(
"accessibility_read_only",
|node: &mut UiNode, read_only: bool| {
node.clone()
.with_attribute("read_only", UiValue::Bool(read_only))
},
);
}
fn register_accessibility_collection_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"accessibility_option",
|node: &mut UiNode,
label: ImmutableString,
selected: bool,
position: INT,
size: INT|
-> Result<UiNode, Box<EvalAltResult>> {
let position = accessibility_positive_index(position, "option position")?;
let size = accessibility_positive_index(size, "option set size")?;
Ok(node
.clone()
.with_attribute("role", UiValue::String("option".to_owned()))
.with_attribute("label", UiValue::String(label.to_string()))
.with_attribute("selected", UiValue::Bool(selected))
.with_attribute("position_in_set", UiValue::Integer(position))
.with_attribute("size_of_set", UiValue::Integer(size)))
},
)
.with_fn(
"accessibility_table_cell",
|node: &mut UiNode,
label: ImmutableString,
column: INT|
-> Result<UiNode, Box<EvalAltResult>> {
let column = accessibility_positive_index(column, "table column index")?;
Ok(node
.clone()
.with_attribute("role", UiValue::String("gridcell".to_owned()))
.with_attribute("label", UiValue::String(label.to_string()))
.with_attribute("column_index", UiValue::Integer(column)))
},
)
.with_fn(
"accessibility_column_header",
|node: &mut UiNode,
label: ImmutableString,
column: INT|
-> Result<UiNode, Box<EvalAltResult>> {
let column = accessibility_positive_index(column, "table column index")?;
Ok(node
.clone()
.with_attribute("role", UiValue::String("columnheader".to_owned()))
.with_attribute("label", UiValue::String(label.to_string()))
.with_attribute("column_index", UiValue::Integer(column)))
},
)
.with_fn(
"accessibility_table_row",
|node: &mut UiNode,
label: ImmutableString,
selected: bool,
row: INT,
position: INT,
size: INT|
-> Result<UiNode, Box<EvalAltResult>> {
let row = accessibility_positive_index(row, "table row index")?;
let position = accessibility_positive_index(position, "row position")?;
let size = accessibility_positive_index(size, "row set size")?;
Ok(node
.clone()
.with_attribute("role", UiValue::String("row".to_owned()))
.with_attribute("label", UiValue::String(label.to_string()))
.with_attribute("selected", UiValue::Bool(selected))
.with_attribute("row_index", UiValue::Integer(row))
.with_attribute("position_in_set", UiValue::Integer(position))
.with_attribute("size_of_set", UiValue::Integer(size)))
},
);
for (method, attribute) in [
("accessibility_level", "level"),
("accessibility_position_in_set", "position_in_set"),
("accessibility_size_of_set", "size_of_set"),
("accessibility_row_index", "row_index"),
("accessibility_column_index", "column_index"),
("accessibility_row_count", "row_count"),
("accessibility_column_count", "column_count"),
] {
let attribute = attribute.to_owned();
builder.with_fn(
method,
move |node: &mut UiNode, value: INT| -> Result<UiNode, Box<EvalAltResult>> {
if value < 1 {
return Err(Box::new(EvalAltResult::ErrorRuntime(
format!("{attribute} must be at least 1").into(),
Position::NONE,
)));
}
Ok(node
.clone()
.with_attribute(attribute.clone(), UiValue::Integer(value)))
},
);
}
}
fn accessibility_positive_index(value: INT, name: &str) -> Result<INT, Box<EvalAltResult>> {
if value < 1 {
Err(Box::new(EvalAltResult::ErrorRuntime(
format!("accessibility {name} must be at least 1").into(),
Position::NONE,
)))
} else {
Ok(value)
}
}
fn register_accessibility_range_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"accessibility_value_min",
|node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
let value = accessibility_number(value, "minimum")?;
Ok(node
.clone()
.with_attribute("value_min", UiValue::Float(value)))
},
)
.with_fn(
"accessibility_value_max",
|node: &mut UiNode, value: Dynamic| -> Result<UiNode, Box<EvalAltResult>> {
let value = accessibility_number(value, "maximum")?;
Ok(node
.clone()
.with_attribute("value_max", UiValue::Float(value)))
},
);
}
fn accessibility_number(value: Dynamic, name: &str) -> Result<f64, Box<EvalAltResult>> {
let value = if value.is::<FLOAT>() {
value.cast::<FLOAT>()
} else if value.is::<INT>() {
value
.cast::<INT>()
.to_string()
.parse::<f64>()
.map_err(|_| {
Box::new(EvalAltResult::ErrorRuntime(
format!("accessibility {name} value cannot be represented").into(),
Position::NONE,
))
})?
} else {
return Err(Box::new(EvalAltResult::ErrorRuntime(
format!("accessibility {name} value must be a number").into(),
Position::NONE,
)));
};
if !value.is_finite() {
return Err(Box::new(EvalAltResult::ErrorRuntime(
format!("accessibility {name} value must be finite").into(),
Position::NONE,
)));
}
Ok(value)
}
fn register_accessibility_reference_methods(builder: &mut TypeBuilder<UiNode>) {
builder
.with_fn(
"accessibility_id",
|node: &mut UiNode, id: ImmutableString| {
node.clone()
.with_attribute("semantic_id", UiValue::String(id.to_string()))
},
)
.with_fn(
"test_id",
|node: &mut UiNode, id: ImmutableString| -> Result<UiNode, Box<EvalAltResult>> {
let id = id.to_string();
if id.is_empty()
|| id.len() > 128
|| !id.chars().all(|character| {
character.is_ascii_alphanumeric()
|| matches!(character, '_' | '-' | '.' | ':')
})
{
return Err(Box::new(EvalAltResult::ErrorRuntime(
"test_id must be 1-128 safe identifier characters".into(),
Position::NONE,
)));
}
Ok(node.clone().with_attribute("test_id", UiValue::String(id)))
},
)
.with_fn(
"accessibility_labelled_by",
|node: &mut UiNode, id: ImmutableString| {
node.clone()
.with_attribute("labelled_by", UiValue::String(id.to_string()))
},
)
.with_fn(
"accessibility_described_by",
|node: &mut UiNode, ids: ImmutableString| {
node.clone()
.with_attribute("described_by", UiValue::String(ids.to_string()))
},
)
.with_fn(
"accessibility_required",
|node: &mut UiNode, required: bool| {
node.clone()
.with_attribute("required", UiValue::Bool(required))
},
);
}
fn dynamic_ui_value(value: Dynamic) -> Result<UiValue, Box<EvalAltResult>> {
UiValue::from_dynamic(value).map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})
}
pub(crate) fn text_node(call: NativeCallContext<'_>, text: ImmutableString) -> UiNode {
with_call_source(UiNode::text(text), call)
}
pub(crate) fn span_value(text: ImmutableString) -> Span {
Span::new(text)
}
pub(crate) fn rich_text_node(
call: NativeCallContext<'_>,
spans: Array,
) -> Result<UiNode, Box<EvalAltResult>> {
let spans = spans
.into_iter()
.enumerate()
.map(|(index, value)| {
value.try_cast::<Span>().ok_or_else(|| {
Box::new(EvalAltResult::ErrorRuntime(
format!("text span {index} must be Span").into(),
Position::NONE,
))
})
})
.collect::<Result<Vec<_>, _>>()?;
let text = spans.iter().map(Span::text).collect::<String>();
let mut boundaries = text
.grapheme_indices(true)
.map(|(index, _)| index)
.collect::<BTreeSet<_>>();
boundaries.insert(text.len());
let mut offset = 0usize;
for (index, span) in spans.iter().enumerate() {
offset = offset.saturating_add(span.text().len());
if !boundaries.contains(&offset) {
return Err(Box::new(EvalAltResult::ErrorRuntime(
format!("text span {index} splits an extended grapheme cluster").into(),
Position::NONE,
)));
}
}
Ok(with_call_source(UiNode::rich_text(spans), call))
}
pub(crate) fn canvas_node(call: NativeCallContext<'_>, scene: crate::CanvasScene) -> UiNode {
with_call_source(UiNode::canvas(scene), call)
}
pub(crate) fn svg_node(
call: NativeCallContext<'_>,
source: &str,
) -> Result<UiNode, Box<EvalAltResult>> {
UiNode::svg(source)
.map(|node| with_call_source(node, call))
.map_err(|error| {
Box::new(EvalAltResult::ErrorRuntime(
error.to_string().into(),
Position::NONE,
))
})
}
pub(crate) fn box_node(
call: NativeCallContext<'_>,
children: Array,
) -> Result<UiNode, Box<rhai::EvalAltResult>> {
collect_children(children, "box")
.map(|children| with_call_source(UiNode::box_node(children), call))
}
pub(crate) fn fragment_node(
call: NativeCallContext<'_>,
children: Array,
) -> Result<UiNode, Box<rhai::EvalAltResult>> {
collect_children(children, "fragment")
.map(|children| with_call_source(UiNode::fragment(children), call))
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn motion_group_node(
call: NativeCallContext<'_>,
id: ImmutableString,
children: Array,
) -> Result<UiNode, Box<EvalAltResult>> {
let id = id.to_string();
if id.is_empty()
|| id.len() > 128
|| !id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return Err(Box::new(EvalAltResult::ErrorRuntime(
"motion group id must be 1-128 safe ASCII characters".into(),
Position::NONE,
)));
}
let mut node = fragment_node(call, children)?;
apply_motion_group(&mut node, &id);
Ok(node)
}
pub(crate) fn apply_motion_group(node: &mut UiNode, group: &str) {
if node.component_root.is_some() {
node.apply_presentation_mutation(NodePresentationMutation::MotionGroup(group.to_owned()));
} else {
apply_motion_group_contents(node, group);
}
}
fn apply_motion_group_contents(node: &mut UiNode, group: &str) {
if node.attributes.contains_key("shared_layout_id")
&& !node.attributes.contains_key("shared_layout_group")
{
node.attributes.insert(
"shared_layout_group".to_owned(),
UiValue::String(group.to_owned()),
);
}
match &mut node.kind {
UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
for child in children {
apply_motion_group(child, group);
}
}
UiNodeKind::Overlay {
trigger, content, ..
} => {
apply_motion_group(trigger, group);
apply_motion_group(content, group);
}
UiNodeKind::Layer { content, .. } => apply_motion_group(content, group),
UiNodeKind::ErrorBoundary { child, fallback } => {
apply_motion_group(child, group);
apply_motion_group(fallback, group);
}
UiNodeKind::VirtualCollection { spec } => {
if spec.inherited_motion_group.is_none() {
spec.inherited_motion_group = Some(group.to_owned());
}
for child in spec.realized.values_mut() {
apply_motion_group(child, group);
}
}
UiNodeKind::Text { .. }
| UiNodeKind::RichText { .. }
| UiNodeKind::Canvas { .. }
| UiNodeKind::Svg { .. }
| UiNodeKind::Custom { .. }
| UiNodeKind::Image { .. }
| UiNodeKind::DirectionalImage { .. } => {}
}
}
pub(crate) fn stack_node(
call: NativeCallContext<'_>,
children: Array,
) -> Result<UiNode, Box<rhai::EvalAltResult>> {
collect_children(children, "stack").map(|children| {
with_call_source(
UiNode::box_node(children).with_style(&Style::new().relative()),
call,
)
})
}
pub(crate) fn column_node(
call: NativeCallContext<'_>,
children: Array,
) -> Result<UiNode, Box<rhai::EvalAltResult>> {
collect_children(children, "column")
.map(|children| with_call_source(UiNode::column(children), call))
}
pub(crate) fn row_node(
call: NativeCallContext<'_>,
children: Array,
) -> Result<UiNode, Box<rhai::EvalAltResult>> {
collect_children(children, "row").map(|children| with_call_source(UiNode::row(children), call))
}
pub(crate) fn error_boundary_node(
call: NativeCallContext<'_>,
child: UiNode,
fallback: UiNode,
) -> UiNode {
with_call_source(UiNode::error_boundary(child, fallback), call)
}
pub(crate) fn lazy_error_boundary_node(
call: NativeCallContext<'_>,
child: FnPtr,
fallback: FnPtr,
) -> Result<UiNode, Box<EvalAltResult>> {
let fallback_node = fallback.call_within_context::<UiNode>(&call, ())?;
let child_result = child.call_within_context::<UiNode>(&call, ());
drop(child);
drop(fallback);
match child_result {
Ok(child) => Ok(with_call_source(
UiNode::error_boundary(child, fallback_node),
call,
)),
Err(error) => Ok(with_call_source(
fallback_node.with_attribute("boundary_error", UiValue::String(error.to_string())),
call,
)),
}
}
pub(crate) fn image_node(call: NativeCallContext<'_>, handle: OpaqueHandle) -> UiNode {
with_call_source(UiNode::image(handle), call)
}
pub(crate) fn asset_image_node(call: NativeCallContext<'_>, asset: AssetId) -> UiNode {
with_call_source(UiNode::asset_image(asset), call)
}
pub(crate) fn generic_image_node(
call: NativeCallContext<'_>,
source: Dynamic,
) -> Result<UiNode, Box<EvalAltResult>> {
Ok(with_call_source(
UiNode::image_source(parse_image_source(source)?),
call,
))
}
pub(crate) fn directional_image_node(
call: NativeCallContext<'_>,
left_to_right: OpaqueHandle,
right_to_left: OpaqueHandle,
) -> UiNode {
with_call_source(
UiNode::directional_image(left_to_right, right_to_left),
call,
)
}
pub(crate) fn directional_asset_image_node(
call: NativeCallContext<'_>,
left_to_right: AssetId,
right_to_left: AssetId,
) -> UiNode {
with_call_source(
UiNode::directional_asset_image(left_to_right, right_to_left),
call,
)
}
pub(crate) fn generic_directional_image_node(
call: NativeCallContext<'_>,
left_to_right: Dynamic,
right_to_left: Dynamic,
) -> Result<UiNode, Box<EvalAltResult>> {
Ok(with_call_source(
UiNode::directional_image_sources(
parse_image_source(left_to_right)?,
parse_image_source(right_to_left)?,
),
call,
))
}
fn parse_image_source(source: Dynamic) -> Result<ImageSourceSpec, Box<EvalAltResult>> {
if source.is::<AssetId>() {
Ok(ImageSourceSpec::Asset(source.cast::<AssetId>()))
} else if source.is::<OpaqueHandle>() {
let handle = source.cast::<OpaqueHandle>();
if handle.kind() == "image" {
Ok(ImageSourceSpec::Handle(handle))
} else {
Err(Box::new(EvalAltResult::ErrorRuntime(
format!(
"image source handle must have kind `image`, got `{}`",
handle.kind()
)
.into(),
Position::NONE,
)))
}
} else {
Err(Box::new(EvalAltResult::ErrorRuntime(
format!(
"image source must be an AssetId or image handle, got {}",
source.type_name()
)
.into(),
Position::NONE,
)))
}
}
pub(crate) fn overlay_node(
call: NativeCallContext<'_>,
trigger: UiNode,
overlay_content: UiNode,
mut config: Map,
) -> Result<UiNode, Box<EvalAltResult>> {
let id = required_string(&mut config, "id")?;
if id.trim().is_empty() {
return overlay_config_error("overlay ID cannot be empty");
}
let parent = optional_string(&mut config, "parent")?;
let kind = match optional_string(&mut config, "kind")?.as_deref() {
None | Some("popover") => OverlayKind::Popover,
Some("combobox") => OverlayKind::Combobox,
Some("tooltip") => OverlayKind::Tooltip,
Some("dialog") => OverlayKind::Dialog,
Some("sheet") => OverlayKind::Sheet,
Some("menu") => OverlayKind::Menu,
Some(other) => return overlay_config_error(format!("unknown overlay kind `{other}`")),
};
let placement = match optional_string(&mut config, "placement")?.as_deref() {
None | Some("bottom") => OverlayPlacement::Bottom,
Some("top") => OverlayPlacement::Top,
Some("left") => OverlayPlacement::Left,
Some("right") => OverlayPlacement::Right,
Some("start") => OverlayPlacement::Start,
Some("end") => OverlayPlacement::End,
Some("center") => OverlayPlacement::Center,
Some(other) => {
return overlay_config_error(format!("unknown overlay placement `{other}`"));
}
};
let open = optional_bool(&mut config, "open")?.unwrap_or(false);
let anchor = optional_overlay_bounds(&mut config, "anchor")?;
let gap = optional_number(&mut config, "gap")?.unwrap_or(8.0);
if !gap.is_finite() || gap < 0.0 {
return overlay_config_error("overlay gap must be finite and non-negative");
}
let modal = optional_bool(&mut config, "modal")?
.unwrap_or(matches!(kind, OverlayKind::Dialog | OverlayKind::Sheet));
let activate_on_trigger = optional_bool(&mut config, "activate_on_trigger")?.unwrap_or(true);
let width_policy = if optional_bool(&mut config, "match_trigger_width")?.unwrap_or(false) {
OverlayWidthPolicy::MatchTrigger
} else {
OverlayWidthPolicy::Content
};
let initial_focus = match optional_string(&mut config, "initial_focus")?.as_deref() {
None | Some("panel") => OverlayInitialFocus::Panel,
Some("first") => OverlayInitialFocus::First,
Some(other) => {
return overlay_config_error(format!(
"unknown overlay initial_focus `{other}` (expected `panel` or `first`)"
));
}
};
let dismiss_on_escape = optional_bool(&mut config, "dismiss_on_escape")?.unwrap_or(true);
let dismiss_on_outside = optional_bool(&mut config, "dismiss_on_outside")?.unwrap_or(true);
let show_delay_ms = optional_usize(&mut config, "show_delay_ms")?;
let hide_delay_ms = optional_usize(&mut config, "hide_delay_ms")?;
let tooltip_delays = (kind == OverlayKind::Tooltip).then(|| TooltipDelays {
show_ms: u64::try_from(show_delay_ms.unwrap_or(500)).unwrap_or(u64::MAX),
hide_ms: u64::try_from(hide_delay_ms.unwrap_or(100)).unwrap_or(u64::MAX),
});
if let Some((unknown, _)) = config.into_iter().next() {
return overlay_config_error(format!("unknown overlay config field `{unknown}`"));
}
Ok(with_call_source(
UiNode::overlay(
trigger,
overlay_content,
OverlayNodeSpec {
id: OverlayId::new(id),
parent: parent.map(OverlayId::new),
kind,
placement,
anchor,
open,
gap,
modal,
dismiss: OverlayDismissPolicy {
escape: dismiss_on_escape,
outside: dismiss_on_outside,
},
tooltip_delays,
initial_focus,
activate_on_trigger,
width_policy,
},
),
call,
))
}
pub(crate) fn layer_node(
call: NativeCallContext<'_>,
content: UiNode,
mut config: Map,
) -> Result<UiNode, Box<EvalAltResult>> {
let id = required_string(&mut config, "id")?;
if id.trim().is_empty() {
return overlay_config_error("layer ID cannot be empty");
}
let placement = match optional_string(&mut config, "placement")?.as_deref() {
None | Some("top_right") => LayerPlacement::TopRight,
Some("top_left") => LayerPlacement::TopLeft,
Some("bottom_left") => LayerPlacement::BottomLeft,
Some("bottom_right") => LayerPlacement::BottomRight,
Some("center") => LayerPlacement::Center,
Some("fill") => LayerPlacement::Fill,
Some(other) => return overlay_config_error(format!("unknown layer placement `{other}`")),
};
let inset = optional_number(&mut config, "inset")?.unwrap_or(12.0);
if !inset.is_finite() || inset < 0.0 {
return overlay_config_error("layer inset must be finite and non-negative");
}
let priority = optional_usize(&mut config, "priority")?.unwrap_or(9_000);
if priority > 1_000_000 {
return overlay_config_error("layer priority cannot exceed 1000000");
}
if let Some((unknown, _)) = config.into_iter().next() {
return overlay_config_error(format!("unknown layer config field `{unknown}`"));
}
Ok(with_call_source(
UiNode::layer(
content,
LayerNodeSpec {
id: OverlayId::new(id),
placement,
inset,
priority,
},
),
call,
))
}
fn with_call_source(node: UiNode, call: NativeCallContext<'_>) -> UiNode {
let position = call.call_position();
let module = call
.call_source()
.or_else(|| call.fn_source())
.map(ToOwned::to_owned);
let _ = std::hint::black_box(call);
let Some(module) = module else {
return node;
};
let (Some(line), Some(column)) = (position.line(), position.position()) else {
return node;
};
let (Ok(line), Ok(column)) = (u32::try_from(line), u32::try_from(column)) else {
return node;
};
node.with_source(SourceLocation {
module,
line,
column,
})
}
fn required_string(config: &mut Map, name: &str) -> Result<String, Box<EvalAltResult>> {
optional_string(config, name)?.ok_or_else(|| {
Box::new(EvalAltResult::ErrorRuntime(
format!("overlay config field `{name}` is required").into(),
Position::NONE,
))
})
}
fn optional_string(config: &mut Map, name: &str) -> Result<Option<String>, Box<EvalAltResult>> {
let Some(value) = config.remove(name) else {
return Ok(None);
};
value
.try_cast::<ImmutableString>()
.map(|value| Some(value.to_string()))
.ok_or_else(|| Box::new(overlay_type_error(name, "string")))
}
fn optional_bool(config: &mut Map, name: &str) -> Result<Option<bool>, Box<EvalAltResult>> {
let Some(value) = config.remove(name) else {
return Ok(None);
};
value
.try_cast::<bool>()
.map(Some)
.ok_or_else(|| Box::new(overlay_type_error(name, "bool")))
}
fn optional_number(config: &mut Map, name: &str) -> Result<Option<f64>, Box<EvalAltResult>> {
let Some(value) = config.remove(name) else {
return Ok(None);
};
if value.is::<INT>() {
value
.cast::<INT>()
.to_string()
.parse::<f64>()
.map(Some)
.map_err(|_| Box::new(overlay_type_error(name, "number")))
} else if value.is::<FLOAT>() {
Ok(Some(value.cast::<FLOAT>()))
} else {
Err(Box::new(overlay_type_error(name, "number")))
}
}
fn optional_usize(config: &mut Map, name: &str) -> Result<Option<usize>, Box<EvalAltResult>> {
let Some(value) = config.remove(name) else {
return Ok(None);
};
value
.try_cast::<INT>()
.and_then(|value| usize::try_from(value).ok())
.map(Some)
.ok_or_else(|| Box::new(overlay_type_error(name, "a non-negative integer")))
}
fn optional_overlay_bounds(
config: &mut Map,
name: &str,
) -> Result<Option<crate::OverlayBounds>, Box<EvalAltResult>> {
let Some(value) = config.remove(name) else {
return Ok(None);
};
let Some(mut bounds) = value.try_cast::<Map>() else {
return Err(Box::new(overlay_type_error(
name,
"a map with x, y, width, and height",
)));
};
let x = optional_number(&mut bounds, "x")?
.ok_or_else(|| Box::new(overlay_type_error("anchor.x", "number")))?;
let y = optional_number(&mut bounds, "y")?
.ok_or_else(|| Box::new(overlay_type_error("anchor.y", "number")))?;
let width = optional_number(&mut bounds, "width")?.unwrap_or(0.0);
let height = optional_number(&mut bounds, "height")?.unwrap_or(0.0);
if let Some((unknown, _)) = bounds.into_iter().next() {
return overlay_config_error(format!("unknown overlay anchor field `{unknown}`"));
}
if !x.is_finite()
|| !y.is_finite()
|| !width.is_finite()
|| width < 0.0
|| !height.is_finite()
|| height < 0.0
{
return overlay_config_error("overlay anchor geometry must be finite and non-negative");
}
Ok(Some(crate::OverlayBounds {
x,
y,
width,
height,
}))
}
fn overlay_type_error(name: &str, expected: &str) -> EvalAltResult {
EvalAltResult::ErrorRuntime(
format!("overlay config field `{name}` must be {expected}").into(),
Position::NONE,
)
}
fn overlay_config_error<T>(message: impl Into<String>) -> Result<T, Box<EvalAltResult>> {
Err(Box::new(EvalAltResult::ErrorRuntime(
message.into().into(),
Position::NONE,
)))
}
fn collect_children(
children: Array,
constructor: &str,
) -> Result<Vec<UiNode>, Box<rhai::EvalAltResult>> {
let mut nodes = Vec::with_capacity(children.len());
for (index, child) in children.into_iter().enumerate() {
match child.try_cast::<UiNode>() {
Some(node) => nodes.push(node),
None => {
return Err(format!("{constructor} child at index {index} is not a UiNode").into());
}
}
}
Ok(nodes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ColorValue, EventPropagation, Length, MotionProperty, MotionTransition, Rgba8, SignalId,
SignalKind, SignalProperty,
};
#[test]
fn node_styles_share_immutable_snapshots_and_mutate_without_aliasing() {
let empty = UiNode::text("empty");
assert!(Rc::ptr_eq(&empty.style, &UiNode::text("other").style));
let original = empty.with_style(
&Style::new()
.opacity(0.25)
.unwrap()
.inset_start(crate::SignedLength::pixels(-4.0).unwrap()),
);
let shared = original.clone();
assert!(Rc::ptr_eq(&original.style, &shared.style));
let changed = shared.with_style(
&Style::new()
.opacity(0.75)
.unwrap()
.inset_end(Length::Pixels(2.0)),
);
assert_eq!(original.style().base.opacity, Some(0.25));
assert!(original.style().base.inset_end.is_none());
assert_eq!(changed.style().base.opacity, Some(0.75));
let ghost = changed.motion_ghost().unwrap();
assert!(ghost.style().base.inset_start.is_none());
assert!(ghost.style().base.inset_end.is_none());
assert!(changed.style().base.inset_start.is_some());
assert!(changed.style().base.inset_end.is_some());
println!(
"UiNode={} bytes; Style={} bytes",
std::mem::size_of::<UiNode>(),
std::mem::size_of::<Style>()
);
assert!(
std::mem::size_of::<UiNode>() <= 2048,
"node stack values must not inline a multi-kilobyte Style"
);
}
#[test]
fn node_identity_style_source_and_attributes_are_runtime_owned() {
let node = UiNode::text("hello")
.with_key("greeting")
.with_style(
&Style::new()
.width(Length::pixels(100.0).unwrap())
.text_color(ColorValue::Literal(Rgba8::from_rgb_hex(0x00ff_ffff))),
)
.with_source(SourceLocation {
module: "ui/main.rhai".to_owned(),
line: 4,
column: 9,
})
.with_attribute("role", UiValue::String("label".to_owned()));
assert_eq!(node.key().map(NodeKey::as_str), Some("greeting"));
assert_eq!(node.source().unwrap().line, 4);
assert_eq!(
node.attributes().get("role"),
Some(&UiValue::String("label".to_owned()))
);
}
#[test]
fn selectable_rejects_non_text_nodes_at_the_script_boundary() {
let mut runtime = crate::RuntimeEngine::new();
let compiled = runtime
.compile_named("selectable.rhai", "fn view() { row([]).selectable(true) }")
.unwrap();
let error = runtime.render(&compiled).unwrap_err();
assert!(
error
.to_string()
.contains("selectable(true) is supported only on text() nodes"),
"{error}"
);
}
#[test]
fn component_replacement_preserves_every_external_presentation_mutation() {
let component = ComponentInstancePath::root("Probe", "owned");
let signal = crate::NativeSignal::new(
SignalId::new(component.clone(), "opacity", SignalKind::Float).unwrap(),
);
let reference =
crate::ElementRef::new(crate::ElementRefId::new(component.clone(), "target").unwrap());
let style = Style::new().width(Length::pixels(120.0).unwrap());
let part_style = Style::new().height(Length::pixels(24.0).unwrap());
let animation = MotionSource::Transition(MotionTransition::new(
MotionProperty::Opacity,
0.0,
1.0,
100,
));
let mut presented = UiNode::text("old")
.with_key("owned")
.with_component_root(component.clone())
.with_key("presented")
.with_style(&style)
.with_part_style("label", part_style.clone())
.with_signal_binding(SignalProperty::Opacity, signal.clone())
.unwrap()
.with_element_ref(reference.clone())
.with_attribute("role", UiValue::String("status".to_owned()))
.with_host_handler(
"click",
crate::HostCallback::new("outer.click", |_, _, _| EventPropagation::Handled),
)
.with_handler_payload("click", UiValue::Integer(7))
.with_motion(animation.clone());
assert_eq!(presented.presentation.len(), 9);
let replacement = UiNode::text("new")
.with_key("replacement")
.with_component_root(component.clone());
assert!(presented.replace_component_subtree(&component, replacement));
assert!(matches!(presented.kind(), UiNodeKind::Text { text } if text == "new"));
assert_eq!(presented.key().map(NodeKey::as_str), Some("presented"));
assert_eq!(presented.style(), &style);
assert_eq!(presented.part_style("label"), Some(&part_style));
assert_eq!(
presented.signal_bindings().collect::<Vec<_>>(),
vec![(SignalProperty::Opacity, &signal)]
);
assert_eq!(presented.element_ref(), Some(&reference));
assert_eq!(
presented.attributes().get("role"),
Some(&UiValue::String("status".to_owned()))
);
assert_eq!(presented.event_handlers("click").len(), 1);
assert_eq!(
presented.handler_payload("click"),
Some(&UiValue::Integer(7))
);
assert_eq!(presented.motions(), &[animation]);
assert_eq!(presented.presentation.len(), 9);
}
#[test]
fn component_snapshot_activates_lazily_and_stays_free_of_outer_presentation() {
let component = ComponentInstancePath::root("Probe", "lazy");
let snapshot = ComponentOwnedSnapshot::default();
let node = UiNode::text("initial")
.with_component_root_snapshot(component.clone(), snapshot.reference());
assert!(snapshot.current().is_none());
let mut presented = node.with_style(&Style::new().opacity(0.5).unwrap());
let owned = snapshot
.current()
.expect("first outer mutation activates snapshot");
assert_eq!(Rc::strong_count(&snapshot.0), 1, "node link must stay weak");
assert!(matches!(owned.kind(), UiNodeKind::Text { text } if text == "initial"));
assert_eq!(owned.style().base.opacity, None);
let replacement =
UiNode::text("updated").with_component_root_snapshot(component, snapshot.reference());
snapshot.update_if_active(&replacement);
presented.hydrate_component_subtrees();
assert!(matches!(presented.kind(), UiNodeKind::Text { text } if text == "updated"));
assert_eq!(presented.style().base.opacity, Some(0.5));
}
#[test]
fn inherited_motion_group_replays_across_nested_component_replacement() {
let outer = ComponentInstancePath::root("Outer", "root");
let inner = outer.child("Inner", "child");
let inner_node = UiNode::text("initial")
.with_shared_layout_id("item")
.with_component_root(inner.clone());
let mut root = UiNode::column(vec![inner_node]).with_component_root(outer);
apply_motion_group(&mut root, "outer-group");
let UiNodeKind::Box { children } = root.kind() else {
panic!("expected component column");
};
assert_eq!(
children[0].attributes().get("shared_layout_group"),
Some(&UiValue::String("outer-group".to_owned()))
);
let replacement = UiNode::text("updated")
.with_shared_layout_id("item")
.with_component_root(inner.clone());
assert!(root.replace_component_subtree(&inner, replacement));
let UiNodeKind::Box { children } = root.kind() else {
panic!("expected component column");
};
assert_eq!(
children[0].attributes().get("shared_layout_group"),
Some(&UiValue::String("outer-group".to_owned()))
);
assert!(matches!(children[0].kind(), UiNodeKind::Text { text } if text == "updated"));
}
fn find_realized<'a>(
node: &'a UiNode,
id: &crate::VirtualCollectionId,
) -> Option<&'a BTreeMap<usize, UiNode>> {
node.virtual_collection_items(id)
}
#[test]
fn realized_items_move_into_nested_collections_without_copies() {
let id = crate::VirtualCollectionId {
component: crate::ComponentInstancePath::root("Table", "readers"),
key: "rows".to_owned(),
};
let collection = || {
UiNode::virtual_collection(crate::VirtualCollectionNodeSpec {
id: id.clone(),
label: "Rows".to_owned(),
data: Vec::<UiValue>::new().into(),
realized: BTreeMap::new(),
estimated_height: 30.0,
height: Some(600.0),
overdraw_pixels: 120.0,
bottom_align: false,
follow_tail: false,
reveal_key: None,
sticky_headers: std::sync::Arc::new(std::collections::BTreeSet::new()),
inherited_motion_group: None,
})
};
let siblings = || {
(0..8)
.map(|index| UiNode::text(format!("sibling {index}")))
.collect::<Vec<_>>()
};
let nested = |inner: UiNode| {
let mut children = siblings();
children.push(UiNode::box_node(vec![UiNode::fragment(vec![inner])]));
UiNode::column(children)
};
let layouts = vec![
nested(collection()),
nested(UiNode::error_boundary(UiNode::text("child"), collection())),
UiNode::row(vec![
nested(UiNode::text("no collection here")),
nested(collection()),
]),
{
let mut outer = collection();
let UiNodeKind::VirtualCollection { spec } = &mut outer.kind else {
unreachable!()
};
spec.id.key = "outer".to_owned();
spec.realized.insert(0, collection());
outer
},
UiNode::custom(PrimitiveNode {
primitive: crate::PrimitiveId::parse("test.slot").unwrap(),
key: None,
props: crate::PrimitiveProps::new().with(
"content",
crate::PrimitiveValue::Node(Box::new(collection())),
),
}),
UiNode::custom(PrimitiveNode {
primitive: crate::PrimitiveId::parse("test.slots").unwrap(),
key: None,
props: crate::PrimitiveProps::new().with(
"content",
crate::PrimitiveValue::Nodes(vec![UiNode::text("prefix"), collection()]),
),
}),
];
for (index, mut root) in layouts.into_iter().enumerate() {
let item = UiNode::text("row")
.with_attribute("probe", UiValue::String(format!("row payload {index}")));
let Some(UiValue::String(payload)) = item.attributes().get("probe") else {
panic!("layout {index}: probe attribute missing before replacement");
};
let payload_ptr = payload.as_ptr();
assert!(root.replace_virtual_collection_items(&id, BTreeMap::from([(7, item)])));
let realized = find_realized(&root, &id).expect("collection keeps the realized items");
let Some(UiValue::String(landed)) = realized[&7].attributes().get("probe") else {
panic!("layout {index}: realized item lost its probe attribute");
};
assert_eq!(
landed.as_ptr(),
payload_ptr,
"layout {index}: realized items were copied"
);
}
}
#[test]
fn key_handler_names_accept_punctuation_keys() {
for script in [
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"?\", Fn(\"h\"), ()) }",
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"[\", Fn(\"h\"), ()) }",
"fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:/\", Fn(\"h\")) }",
"fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:-\", Fn(\"h\")) }",
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"escape\", Fn(\"h\"), ()) }",
] {
let mut runtime = crate::RuntimeEngine::new();
let compiled = runtime.compile_named("keys.rhai", script).unwrap();
runtime.render(&compiled).unwrap_or_else(|error| {
panic!("script should register its key handler: {script}\n{error}")
});
}
}
#[test]
fn key_handler_names_reject_invalid_key_names() {
for script in [
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"\", Fn(\"h\"), ()) }",
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"a b\", Fn(\"h\"), ()) }",
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"héllo\", Fn(\"h\"), ()) }",
"fn h(ctx, payload) { () } fn view() { text(\"x\").on_key_value(\"a:b\", Fn(\"h\"), ()) }",
"fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:\", Fn(\"h\")) }",
"fn h(ctx, event) { () } fn view() { text(\"x\").on(\"key:a b\", Fn(\"h\")) }",
] {
let mut runtime = crate::RuntimeEngine::new();
let compiled = runtime.compile_named("keys.rhai", script).unwrap();
assert!(
runtime.render(&compiled).is_err(),
"script should be rejected: {script}"
);
}
}
fn key_registration_script(method: &str, key: &str) -> String {
let name = if method == "on_key_value" {
key.to_owned()
} else {
format!("key:{key}")
};
let name = serde_json::to_string(&name).unwrap();
let payload = if method == "on_key_value" { ", 7" } else { "" };
format!(
"fn h(ctx,p){{()}} fn view(){{text(\"target\").{method}({name},Fn(\"h\"){payload})}}"
)
}
#[test]
fn all_rhai_key_entries_share_normalization_and_exact_limits() {
for (method, phase) in [
("on_key_value", crate::EventPhase::Target),
("on", crate::EventPhase::Target),
("on_capture", crate::EventPhase::Capture),
("on_bubble", crate::EventPhase::Bubble),
] {
let long = "A".repeat(60);
for key in [
"Escape", "A", "1", "page_up", "?", "/", "[", "]", "-", "=", &long,
] {
let mut runtime = crate::RuntimeEngine::new();
let compiled = runtime
.compile_named("key_contract.rhai", &key_registration_script(method, key))
.unwrap();
let node = runtime.render(&compiled).unwrap();
let event = format!("key:{}", key.to_ascii_lowercase());
assert_eq!(node.handlers().len(), 1, "{method}, {key:?}");
assert_eq!(node.event_handlers(&event)[0].phase(), phase);
if method == "on_key_value" {
assert_eq!(node.handler_payload(&event), Some(&UiValue::Integer(7)));
}
}
let long = "a".repeat(61);
for key in [
"", " ", " escape", "escape ", "\t", "\n", "\0", ":", "a:b", "中文", "é", "cmd-s",
"shift-/", "??", "[]", &long,
] {
let mut runtime = crate::RuntimeEngine::new();
let compiled = runtime
.compile_named("key_contract.rhai", &key_registration_script(method, key))
.unwrap();
assert!(
runtime.render(&compiled).is_err(),
"{method} accepted {key:?}"
);
}
}
}
#[test]
fn key_normalization_does_not_change_ordinary_event_namespaces() {
for event in ["click", "value_changed", "custom:event", ":", "::"] {
assert_eq!(normalize_node_event_name(event).unwrap(), event);
}
for event in [
"",
"Click",
"custom:Event",
"Key:Escape",
"custom-event",
"custom:?",
"a b",
] {
assert!(
normalize_node_event_name(event).is_err(),
"accepted {event:?}"
);
}
assert!(normalize_node_event_name(&"a".repeat(64)).is_ok());
assert!(normalize_node_event_name(&"a".repeat(65)).is_err());
}
}