beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use crate::build_pending::UiBuildPending;
use crate::style::{
    apply_utility_patch, form_item_compact_width, resolve_class_patch_or_empty,
    resolve_classes_with_fallback, root_visual_styles_from_patch,
};
use crate::text::AddText;
use bevy::prelude::*;
use bevy::text::TextLayout;
use bevy::ui::UiScale;
use bevy_localization::TextKey;

const DEFAULT_FORM_ITEM_CLASS: &str = "form-item";
const DEFAULT_FORM_ITEM_COMPACT_CLASS: &str = "form-item-compact";
const DEFAULT_FORM_ITEM_LABEL_CLASS: &str = "form-item-label";
const DEFAULT_FORM_ITEM_LABEL_COMPACT_CLASS: &str = "form-item-label-compact";

#[derive(Component, Default, Debug, Clone)]
pub struct FormItem {
    compact: Option<bool>,
}

#[derive(Component, Default, Debug, Clone)]
pub struct FormItemLabel;

#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FormItemSet {
    Build,
}

impl Plugin for FormItem {
    fn build(&self, app: &mut App) {
        app.add_systems(
            Update,
            (
                add_form_item.in_set(FormItemSet::Build),
                sync_form_item_layouts,
            ),
        );
    }
}

#[derive(Component, Default, Debug, Clone, PartialEq, Eq)]
pub struct AddFormItem {
    pub text: String,
    pub text_key: Option<TextKey>,
}

impl AddFormItem {
    pub fn localized(text: impl Into<String>, text_key: Option<TextKey>) -> Self {
        Self {
            text: text.into(),
            text_key,
        }
    }
}

pub fn form_item_node() -> Node {
    let mut node = Node::default();
    let patch = resolve_class_patch_or_empty(DEFAULT_FORM_ITEM_CLASS, "form item");
    apply_utility_patch(&mut node, &patch);
    node
}

pub fn form_item_label_node() -> Node {
    let mut node = Node::default();
    let patch = resolve_class_patch_or_empty(DEFAULT_FORM_ITEM_LABEL_CLASS, "form item label");
    apply_utility_patch(&mut node, &patch);
    node
}

fn add_form_item(mut commands: Commands, query: Query<(Entity, &AddFormItem)>) {
    for (entity, add_form_item) in query {
        let add_form_item = add_form_item.clone();
        commands
            .entity(entity)
            .queue_silenced(move |mut entity: EntityWorldMut| {
                let patch = resolve_class_patch_or_empty(DEFAULT_FORM_ITEM_CLASS, "form item");
                entity.insert((
                    FormItem::default(),
                    form_item_node(),
                    Visibility::Visible,
                    BackgroundColor(Color::NONE),
                ));
                if let Some(styles) = root_visual_styles_from_patch(&patch) {
                    entity.insert(styles);
                }

                let child = entity.world_scope(|world| {
                    world
                        .spawn((
                            FormItemLabel,
                            form_item_label_node(),
                            TextLayout::default(),
                            AddText {
                                text: add_form_item.text.clone(),
                                localized_text: add_form_item.text_key,
                                ..default()
                            },
                        ))
                        .id()
                });

                entity.insert_child(0, child);
                entity.remove::<AddFormItem>().remove::<UiBuildPending>();
            });
    }
}

fn sync_form_item_layouts(
    ui_scale: Res<UiScale>,
    mut items: Query<
        (&mut Node, Ref<ComputedNode>, &Children, &mut FormItem),
        (With<FormItem>, Without<FormItemLabel>),
    >,
    mut label_nodes: Query<&mut Node, (With<FormItemLabel>, Without<FormItem>)>,
) {
    let sync_all = ui_scale.is_changed();
    let compact_width = form_item_compact_width();

    for (mut item_node, computed, children, mut item) in &mut items {
        if !(sync_all || item.is_added() || computed.is_changed()) {
            continue;
        }

        let compact = computed.size().x < compact_width;
        if item.compact == Some(compact) && !sync_all {
            continue;
        }
        item.compact = Some(compact);

        *item_node = form_item_node();
        let patch = resolve_classes_with_fallback(
            DEFAULT_FORM_ITEM_CLASS,
            compact.then_some(DEFAULT_FORM_ITEM_COMPACT_CLASS),
            "form item",
        );
        apply_utility_patch(&mut item_node, &patch);

        for child in children.iter() {
            if let Ok(mut label_node) = label_nodes.get_mut(child) {
                *label_node = form_item_label_node();
                let label_patch = resolve_classes_with_fallback(
                    DEFAULT_FORM_ITEM_LABEL_CLASS,
                    compact.then_some(DEFAULT_FORM_ITEM_LABEL_COMPACT_CLASS),
                    "form item label",
                );
                apply_utility_patch(&mut label_node, &label_patch);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn form_item_plugin_initializes_without_conflicting_queries() {
        let mut app = App::new();
        app.init_resource::<UiScale>()
            .add_plugins(FormItem::default());

        app.update();
    }
}