beuvy 0.1.0

Facade crate for beuvy-runtime plus optional declarative UI authoring.
Documentation
use super::bindings::{condition_expr_matches, conditional_matches};
use super::context::DeclarativeUiBuildContext;
use super::text::{button_text_content, default_option_value, typography_with_override};
use crate::ast::*;
use beuvy_runtime::button::AddButton;
use beuvy_runtime::input::{AddInput, InputType};
use beuvy_runtime::text::control_typography;
use beuvy_runtime::{AddSelect, AddSelectOption};
use bevy::prelude::default;

impl From<DeclarativeButtonType> for beuvy_runtime::button::ButtonType {
    fn from(value: DeclarativeButtonType) -> Self {
        match value {
            DeclarativeButtonType::Button => Self::Button,
            DeclarativeButtonType::Submit => Self::Submit,
            DeclarativeButtonType::Reset => Self::Reset,
        }
    }
}

pub(crate) fn build_declarative_button(
    node: &DeclarativeUiNode,
    context: &DeclarativeUiBuildContext,
) -> AddButton {
    let DeclarativeUiNode::Button {
        name,
        button_type,
        class,
        content,
        disabled,
        disabled_expr,
        show_expr,
        label_typography_override,
        ..
    } = node
    else {
        unreachable!();
    };

    let (text, localized_text, localized_text_format) = button_text_content(content, context);
    AddButton {
        name: name.clone(),
        button_type: (*button_type).into(),
        text,
        localized_text,
        localized_text_format,
        class: (!class.is_empty()).then_some(class.clone()),
        label_typography: typography_with_override(
            control_typography(),
            label_typography_override,
        ),
        disabled: disabled_expr
            .as_ref()
            .map(|expr| condition_expr_matches(expr, context))
            .unwrap_or(*disabled),
        visible: show_expr
            .as_ref()
            .map(|expr| condition_expr_matches(expr, context))
            .unwrap_or(true),
        ..default()
    }
}

pub(crate) fn build_declarative_input(
    node: &DeclarativeUiNode,
    context: &DeclarativeUiBuildContext,
) -> AddInput {
    let DeclarativeUiNode::Input {
        name,
        class,
        input_type,
        value,
        checked,
        checked_binding,
        value_binding,
        model_binding,
        placeholder,
        size_chars,
        rows,
        min,
        max,
        step,
        disabled,
        disabled_expr,
        ..
    } = node
    else {
        unreachable!();
    };

    AddInput {
        name: name.clone(),
        input_type: *input_type,
        value: resolved_input_value(
            *input_type,
            value,
            model_binding.as_deref().or(value_binding.as_deref()),
            context,
        ),
        checked: resolved_input_checked(
            *input_type,
            *checked,
            checked_binding.as_deref(),
            value,
            model_binding.as_deref().or(value_binding.as_deref()),
            context,
        ),
        placeholder: placeholder.clone(),
        size_chars: *size_chars,
        rows: *rows,
        min: *min,
        max: *max,
        step: *step,
        class: (!class.is_empty()).then_some(class.clone()),
        disabled: disabled_expr
            .as_ref()
            .map(|expr| condition_expr_matches(expr, context))
            .unwrap_or(*disabled),
        ..default()
    }
}

fn resolved_input_value(
    input_type: InputType,
    value: &str,
    value_binding: Option<&str>,
    context: &DeclarativeUiBuildContext,
) -> String {
    if matches!(input_type, InputType::Checkbox) {
        return if value.is_empty() {
            "true".to_string()
        } else {
            value.to_string()
        };
    }
    if matches!(input_type, InputType::Radio) {
        return value.to_string();
    }
    if matches!(input_type, InputType::Number | InputType::Range) {
        return value_binding
            .and_then(|binding| {
                context
                    .number(binding)
                    .map(|value| value.to_string())
                    .or_else(|| context.text(binding).map(str::to_string))
            })
            .unwrap_or_else(|| value.to_string());
    }

    value_binding
        .and_then(|binding| context.text(binding).map(str::to_string))
        .unwrap_or_else(|| value.to_string())
}

fn resolved_input_checked(
    input_type: InputType,
    checked: bool,
    checked_binding: Option<&str>,
    value: &str,
    value_binding: Option<&str>,
    context: &DeclarativeUiBuildContext,
) -> bool {
    if let Some(binding) = checked_binding
        && let Some(value) = context.bool(binding)
    {
        return value;
    }

    match input_type {
        InputType::Checkbox => value_binding
            .and_then(|binding| context.bool(binding))
            .unwrap_or(checked),
        InputType::Radio => value_binding
            .and_then(|binding| context.text(binding))
            .map(|bound| bound == value)
            .unwrap_or(checked),
        _ => checked,
    }
}

pub(crate) fn build_declarative_select(
    node: &DeclarativeUiNode,
    context: &DeclarativeUiBuildContext,
) -> AddSelect {
    let DeclarativeUiNode::Select {
        name,
        class,
        value,
        value_binding,
        model_binding,
        options,
        disabled,
        disabled_expr,
        label_typography_override,
        ..
    } = node
    else {
        unreachable!();
    };

    let built_options = options
        .iter()
        .flat_map(|option| build_select_options(name, option, context))
        .enumerate()
        .map(|(index, mut option)| {
            option.name = format!("{name}_{index}_option");
            option
        })
        .collect::<Vec<_>>();
    let value = resolved_select_value(
        value,
        model_binding.as_deref().or(value_binding.as_deref()),
        &built_options,
        options,
        context,
    );

    AddSelect {
        name: name.clone(),
        value,
        options: built_options,
        class: (!class.is_empty()).then_some(class.clone()),
        label_typography: typography_with_override(
            control_typography(),
            label_typography_override,
        ),
        disabled: disabled_expr
            .as_ref()
            .map(|expr| condition_expr_matches(expr, context))
            .unwrap_or(*disabled),
        ..default()
    }
}

fn build_select_options(
    select_name: &str,
    option: &DeclarativeSelectOption,
    context: &DeclarativeUiBuildContext,
) -> Vec<AddSelectOption> {
    let mut built = Vec::new();

    if let Some(repeat) = &option.repeat {
        for (repeat_index, item) in context
            .template_items(&repeat.source)
            .iter()
            .cloned()
            .enumerate()
        {
            let repeated_context = context.with_template_iteration(
                item,
                &repeat.item_alias,
                repeat.index_alias.as_deref(),
                repeat_index,
            );
            if !conditional_matches(&option.conditional, &repeated_context) {
                continue;
            }
            built.push(build_select_option(
                select_name,
                built.len(),
                option,
                &repeated_context,
            ));
        }
        return built;
    }

    if conditional_matches(&option.conditional, context) {
        built.push(build_select_option(select_name, 0, option, context));
    }

    built
}

fn build_select_option(
    select_name: &str,
    index: usize,
    option: &DeclarativeSelectOption,
    context: &DeclarativeUiBuildContext,
) -> AddSelectOption {
    let (text, localized_text, localized_text_format) =
        button_text_content(&option.content, context);
    AddSelectOption {
        name: format!("{select_name}_{index}_option"),
        value: option
            .value_binding
            .as_deref()
            .and_then(|binding| context.string(binding))
            .or_else(|| option.value.clone())
            .unwrap_or_else(|| default_option_value(&option.content, &text, context)),
        text,
        localized_text,
        localized_text_format,
        disabled: option
            .disabled_expr
            .as_ref()
            .map(|expr| condition_expr_matches(expr, context))
            .unwrap_or(option.disabled),
    }
}

fn resolved_select_value(
    value: &str,
    value_binding: Option<&str>,
    options: &[AddSelectOption],
    declarative_options: &[DeclarativeSelectOption],
    context: &DeclarativeUiBuildContext,
) -> String {
    if let Some(binding) = value_binding
        && let Some(value) = context.text(binding)
    {
        return value.to_string();
    }
    if !value.is_empty() {
        return value.to_string();
    }
    if let Some(selected) = first_selected_option_value(declarative_options, context) {
        return selected;
    }
    options
        .first()
        .map(|option| option.value.clone())
        .unwrap_or_default()
}

fn first_selected_option_value(
    options: &[DeclarativeSelectOption],
    context: &DeclarativeUiBuildContext,
) -> Option<String> {
    for option in options {
        if let Some(repeat) = &option.repeat {
            for (repeat_index, item) in context
                .template_items(&repeat.source)
                .iter()
                .cloned()
                .enumerate()
            {
                let repeated_context = context.with_template_iteration(
                    item,
                    &repeat.item_alias,
                    repeat.index_alias.as_deref(),
                    repeat_index,
                );
                if conditional_matches(&option.conditional, &repeated_context) && option.selected {
                    return Some(
                        option
                            .value_binding
                            .as_deref()
                            .and_then(|binding| repeated_context.string(binding))
                            .or_else(|| option.value.clone())
                            .unwrap_or_default(),
                    );
                }
            }
            continue;
        }

        if conditional_matches(&option.conditional, context) && option.selected {
            return Some(
                option
                    .value_binding
                    .as_deref()
                    .and_then(|binding| context.string(binding))
                    .or_else(|| option.value.clone())
                    .unwrap_or_default(),
            );
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{UiValue, parse_declarative_ui_asset};

    #[test]
    fn numeric_input_value_binding_accepts_text_value() {
        let asset = parse_declarative_ui_asset(
            r#"<template><input type="range" :value="slider_value" /></template>"#,
        )
        .expect("input should parse");
        let context = DeclarativeUiBuildContext::default()
            .with_root(UiValue::object([("slider_value", UiValue::from("1.0"))]));

        let input = build_declarative_input(&asset.root, &context);

        assert_eq!(input.value, "1.0");
    }
}