/woodpecker_ui

Primary LanguageRustApache License 2.0Apache-2.0

Crates.io docs License Crates.io

Woodpecker UI

Woodpecker UI is a Bevy ECS driven user interface crate. Its designed to be easy to use and work seamlessly with the bevy game engine.

Features

  • ECS first UI
  • Easy to use widget systems
  • Flexable UI rendering using vello
  • Taffy layouting
  • Cosmic Text for text layouting
  • A few helper widgets to get you started

Running on desktop:

cargo run --example todo

Running on WASM:

  1. cargo install wasm-server-runner
  2. RUSTFLAGS="--cfg=web_sys_unstable_apis" cargo run --example todo --target wasm32-unknown-unknown --release

Found a bug? Please open an issue!

Basic Example examples/text.rs:

use bevy::prelude::*;
use bevy_mod_picking::DefaultPickingPlugins;
use woodpecker_ui::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(WoodpeckerUIPlugin::default())
        .add_plugins(DefaultPickingPlugins)
        .add_systems(Startup, startup)
        .run();
}

fn startup(mut commands: Commands, mut ui_context: ResMut<WoodpeckerContext>) {
    commands.spawn(Camera2dBundle::default());

    let root = commands
        .spawn(WoodpeckerAppBundle {
            children: WidgetChildren::default().with_child::<Element>((
                ElementBundle {
                    styles: WoodpeckerStyle {
                        font_size: 50.0,
                        color: Srgba::RED.into(),
                        margin: Edge::all(10.0),
                        ..Default::default()
                    },
                    ..Default::default()
                },
                WidgetRender::Text {
                    content: "Hello World! I am Woodpecker UI!".into(),
                    word_wrap: false,
                },
            )),
            ..Default::default()
        })
        .id();
    ui_context.set_root_widget(root);
}
Counter Example
use bevy::prelude::*;
use bevy_mod_picking::{
    events::{Click, Pointer},
    prelude::On,
    DefaultPickingPlugins,
};
use woodpecker_ui::prelude::*;

#[derive(Component, PartialEq, Default, Debug, Clone)]
pub struct CounterState {
    count: u32,
}

#[derive(Widget, Component, Reflect, PartialEq, Default, Debug, Clone)]
#[auto_update(render)]
#[props(CounterWidget)]
#[state(CounterState)]
pub struct CounterWidget {
    initial_count: u32,
}

#[derive(Bundle, Default, Clone)]
pub struct CounterWidgetBundle {
    pub counter: CounterWidget,
    pub styles: WoodpeckerStyle,
    pub children: WidgetChildren,
}

fn render(
    current_widget: Res<CurrentWidget>,
    mut commands: Commands,
    mut query: Query<(&CounterWidget, &mut WidgetChildren)>,
    state_query: Query<&CounterState>,
    mut hooks: ResMut<HookHelper>,
) {
    let Ok((widget, mut children)) = query.get_mut(**current_widget) else {
        return;
    };

    let state_entity = hooks.use_state(
        &mut commands,
        *current_widget,
        CounterState {
            count: widget.initial_count,
        },
    );

    let Ok(state) = state_query.get(state_entity) else {
        return;
    };

    // Dereference so we don't move the reference into the on click closure.
    let current_widget = *current_widget;
    *children = WidgetChildren::default().with_child::<Element>(ElementBundle {
        styles: WoodpeckerStyle {
            width: Units::Percentage(100.0),
            flex_direction: WidgetFlexDirection::Column,
            justify_content: Some(WidgetAlignContent::Center),
            align_items: Some(WidgetAlignItems::Center),
            ..Default::default()
        },
        children: WidgetChildren::default()
            .with_child::<Element>((
                ElementBundle {
                    styles: WoodpeckerStyle {
                        font_size: 50.0,
                        margin: Edge::all(10.0),
                        ..Default::default()
                    },
                    ..Default::default()
                },
                WidgetRender::Text {
                    content: format!("Current Count: {}", state.count),
                    word_wrap: false,
                },
            ))
            .with_child::<WButton>((
                WButtonBundle {
                    children: WidgetChildren::default().with_child::<Element>((
                        ElementBundle {
                            styles: WoodpeckerStyle {
                                font_size: 14.0,
                                margin: Edge::all(10.0),
                                ..Default::default()
                            },
                            ..Default::default()
                        },
                        WidgetRender::Text {
                            content: "Increase Count".into(),
                            word_wrap: false,
                        },
                    )),
                    ..Default::default()
                },
                On::<Pointer<Click>>::run(move |mut query: Query<&mut CounterState>| {
                    let Ok(mut state) = query.get_mut(state_entity) else {
                        return;
                    };
                    state.count += 1;
                }),
            )),
        ..Default::default()
    });

    children.apply(current_widget.as_parent());
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(WoodpeckerUIPlugin::default())
        .add_plugins(DefaultPickingPlugins)
        .add_systems(Startup, startup)
        .register_widget::<CounterWidget>()
        .run();
}

fn startup(
    mut commands: Commands,
    mut ui_context: ResMut<WoodpeckerContext>,
    mut font_manager: ResMut<FontManager>,
    asset_server: Res<AssetServer>,
) {
    commands.spawn(Camera2dBundle::default());

    let font = asset_server.load("Outfit/static/Outfit-Regular.ttf");
    font_manager.add(&font);

    let root = commands
        .spawn(WoodpeckerAppBundle {
            children: WidgetChildren::default().with_child::<CounterWidget>(CounterWidgetBundle {
                styles: WoodpeckerStyle {
                    width: Units::Percentage(100.0),
                    ..Default::default()
                },
                ..Default::default()
            }),
            ..Default::default()
        })
        .id();
    ui_context.set_root_widget(root);
}

Q and A

Q1. Why not use Bevy UI?

  1. Bevy UI rendering leaves a lot to be desired. Woodpecker UI uses vello a newer rendering system for UI's. It supports everything that I was looking for in a UI renderer.
  2. Bevy UI is designed to be an immediate mode UI similar to egui. Woodpecker UI is reactive and only changes the widget tree when a widget changes and in the future will only render to the screen when changed.

Q2. Why not use one of the other UI libraries out there?

  1. A lot of times they don't integrate into the ECS very nicely. They tend to want ownership of the data which means it must live outside of bevy's ECS world. I have problems with this.
  2. Non-Rust syntax. Woodpecker UI uses rust syntax for everything, a for loop is a for loop, an if statement is an if statement. There is no custom wrappers for these things in Woodpecker UI. Which makes writing code a lot easier! See: examples/todo/list.rs Line 53
  3. They use Bevy UI. See the Bevy UI section above.

Q3. What about Kayak UI?

You might notice the syntax used here is quite similar to Kayak UI, but Kayak UI suffered from overly complicated internals. It made contributing to Kayak UI much too difficult and caused quite a few fundamental bugs. In Woodpecker UI I took what made Kayak UI great and made the backend much much simpler. As an example the primiary system that runs the UI was over 1k lines in Kayak and in Woodpecker its less than 200! This should help foster collaborative development and encourage people to help fix bugs!

Q4. Why not wait for the next-gen Bevy UI? Why make your own?

  1. There is no timeline for when this might come out.
  2. There are a lot of conflicting opinions about how the next-gen Bevy UI should work. In my opinion there isn't a clear direction(yet although its starting to form). How does it render things? What about input eventing? I hope/believe this will change for the better!
  3. So far I'm personally not a huge fan of using scenes and also the new BSN macro. From what I've seen it has some problems around not using rust syntax, data management, and although you can opt out of using BSN you cannot opt out of using scenes and entity patches for UI. Although thats not completely clear yet.
  4. I apparently really like writing UI crates.

Q5. Should I use Woodpecker UI?

I would look at the features and like any other crate that you pick you should weigh your options and pick the one best suited to your needs. I don't claim that Woodpecker UI will fit any need and its really up to the individual to decide.

License

Woodpecker UI is free, open source and permissively licensed! Except where noted (below and/or in individual files), all code in this repository is dual-licensed under either:

at your option. This means you can select the license you prefer! This dual-licensing approach is the de-facto standard in the Rust ecosystem and there are very good reasons to include both.

Some of the engine's code carries additional copyright notices and license terms due to their external origins. These are generally BSD-like, but exact details vary by crate: If the README of a crate contains a 'License' header (or similar), the additional copyright notices and license terms applicable to that crate will be listed. The above licensing requirement still applies to contributions to those crates, and sections of those crates will carry those license terms.