beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use beuvy_runtime::button::ButtonClickMessage;
use beuvy_runtime::input::{InputType, InputValueChangedMessage};
use beuvy_runtime::text::FontResource;
use beuvy_runtime::text::set_plain_text;
use beuvy_runtime::{
    AddButton, AddInput, AddSelect, AddSelectOption, AddText, SelectValueChangedMessage,
    UiKitPlugin,
};
use bevy::prelude::*;
use bevy::text::TextLayout;

#[derive(Component)]
struct EventLogText;

#[derive(Resource, Default)]
struct EventLog {
    entries: Vec<String>,
}

fn main() {
    App::new()
        .init_resource::<EventLog>()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "beuvy-runtime control events".to_string(),
                resolution: (1180, 720).into(),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(UiKitPlugin)
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                record_button_events,
                record_input_events,
                record_select_events,
            ),
        )
        .run();
}

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);
    commands.insert_resource(FontResource::from_handle(
        asset_server.load("fonts/SarasaFixedSC-Regular.ttf"),
    ));

    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                padding: UiRect::all(Val::Px(24.0)),
                column_gap: Val::Px(20.0),
                overflow: Overflow::visible(),
                ..default()
            },
            BackgroundColor(Color::srgb_u8(248, 250, 252)),
        ))
        .with_children(|parent| {
            let mut left = parent.spawn(Node {
                width: Val::Px(420.0),
                padding: UiRect::all(Val::Px(16.0)),
                row_gap: Val::Px(12.0),
                flex_direction: FlexDirection::Column,
                overflow: Overflow::visible(),
                border_radius: BorderRadius::all(Val::Px(12.0)),
                ..default()
            });
            left.insert(BorderColor::all(Color::srgb_u8(203, 213, 225)));
            left.insert(BackgroundColor(Color::WHITE));
            left.with_children(|parent| {
                spawn_text(
                    parent,
                    "Interact with these controls",
                    18.0,
                    Color::srgb_u8(15, 23, 42),
                );
                parent.spawn(AddInput {
                    name: "display_name".to_string(),
                    placeholder: "Type a display name".to_string(),
                    size_chars: Some(24),
                    ..default()
                });
                parent.spawn(AddInput {
                    name: "zoom".to_string(),
                    input_type: InputType::Range,
                    value: "35".to_string(),
                    min: Some(0.0),
                    max: Some(100.0),
                    step: Some(5.0),
                    ..default()
                });
                parent.spawn(AddSelect {
                    name: "theme".to_string(),
                    value: "light".to_string(),
                    options: vec![
                        option("theme_light", "light", "Light"),
                        option("theme_dark", "dark", "Dark"),
                        option("theme_hc", "high-contrast", "High Contrast"),
                    ],
                    ..default()
                });
                parent
                    .spawn(Node {
                        column_gap: Val::Px(10.0),
                        flex_wrap: FlexWrap::Wrap,
                        ..default()
                    })
                    .with_children(|parent| {
                        parent.spawn(AddButton {
                            name: "save".to_string(),
                            text: "Save".to_string(),
                            class: Some("button-root w-[120px]".to_string()),
                            ..default()
                        });
                        parent.spawn(AddButton {
                            name: "reset".to_string(),
                            text: "Reset".to_string(),
                            class: Some("button-root w-[120px]".to_string()),
                            ..default()
                        });
                    });
            });

            let mut right = parent.spawn(Node {
                flex_grow: 1.0,
                padding: UiRect::all(Val::Px(16.0)),
                row_gap: Val::Px(10.0),
                flex_direction: FlexDirection::Column,
                overflow: Overflow::visible(),
                border_radius: BorderRadius::all(Val::Px(12.0)),
                ..default()
            });
            right.insert(BorderColor::all(Color::srgb_u8(203, 213, 225)));
            right.insert(BackgroundColor(Color::WHITE));
            right.with_children(|parent| {
                spawn_text(parent, "Event log", 18.0, Color::srgb_u8(15, 23, 42));
                parent.spawn((
                    EventLogText,
                    Node {
                        width: Val::Percent(100.0),
                        ..default()
                    },
                    TextLayout::default(),
                    AddText {
                        text: "No events yet.\nClick, type, drag, or select.".to_string(),
                        size: 14.0,
                        color: Color::srgb_u8(71, 85, 105),
                        ..default()
                    },
                ));
            });
        });
}

fn record_button_events(
    mut commands: Commands,
    mut events: MessageReader<ButtonClickMessage>,
    mut log: ResMut<EventLog>,
    labels: Query<Entity, With<EventLogText>>,
) {
    let mut changed = false;
    for event in events.read() {
        log.entries.push(format!(
            "button:{} on {:?}",
            event.button.name, event.entity
        ));
        changed = true;
    }
    if changed {
        sync_event_log_text(&mut commands, &log, &labels);
    }
}

fn record_input_events(
    mut commands: Commands,
    mut events: MessageReader<InputValueChangedMessage>,
    mut log: ResMut<EventLog>,
    labels: Query<Entity, With<EventLogText>>,
) {
    let mut changed = false;
    for event in events.read() {
        log.entries
            .push(format!("input:{} = {}", event.name, event.value));
        changed = true;
    }
    if changed {
        sync_event_log_text(&mut commands, &log, &labels);
    }
}

fn record_select_events(
    mut commands: Commands,
    mut events: MessageReader<SelectValueChangedMessage>,
    mut log: ResMut<EventLog>,
    labels: Query<Entity, With<EventLogText>>,
) {
    let mut changed = false;
    for event in events.read() {
        log.entries
            .push(format!("select:{} = {}", event.name, event.value));
        changed = true;
    }
    if changed {
        sync_event_log_text(&mut commands, &log, &labels);
    }
}

fn sync_event_log_text(
    commands: &mut Commands,
    log: &EventLog,
    labels: &Query<Entity, With<EventLogText>>,
) {
    let Some(entity) = labels.iter().next() else {
        return;
    };
    let text = log
        .entries
        .iter()
        .rev()
        .take(12)
        .cloned()
        .collect::<Vec<_>>()
        .join("\n");
    set_plain_text(commands, entity, text);
}

fn option(name: &str, value: &str, text: &str) -> AddSelectOption {
    AddSelectOption {
        name: name.to_string(),
        value: value.to_string(),
        text: text.to_string(),
        localized_text: None,
        localized_text_format: None,
        disabled: false,
    }
}

fn spawn_text(parent: &mut ChildSpawnerCommands, text: &str, size: f32, color: Color) {
    parent.spawn((
        Node {
            width: Val::Percent(100.0),
            ..default()
        },
        TextLayout::default(),
        AddText {
            text: text.to_string(),
            size,
            color,
            ..default()
        },
    ));
}