Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Build high performance GUI app with power of Game Engine (Bevy).

Makara is a UI Library built on top of Bevy engine.

Bevy is a game engine written in Rust, based on ECS architecture. It’s capable of rendering 2D and 3D graphics.

Bevy also provides full UI system to build GPU accelerated GUI application but because it’s based on ECS, building UI with Bevy is verbosity.

Let’s see an example below of creating a simple button with pure Bevy’s UI.

#![allow(unused)]
fn main() {
fn setup(mut commands: Commands) {
    commands.spawn((
        Node {
            width: px(100),
            height: px(20),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            padding: UiRect::all(px(10)),
            ..default()
        },
        BorderRadius::all(px(8)),
        BackgroundColor(Color::srgb(1.0, 1.0, 1.0)),
        
        children![
            (
                Text::new("Click me"),
                TextColor(Color::BLACK)
            )
        ],
    
        observe(on_button_click)
    ))
}

fn on_button_click(_click: On<Pointer<Click>>) {
    // do something
}
}

As you can see, it’s really easy to get lost with Bevy’s UI. A real GUI application will have more than just a button and complex hierarchy.

Makara simplifies this problem but providing built-in widgets and high level api with builder pattern that most rust devs familiar with, to make it easy to write and maintain the code.

Let’s see how Makara reduce the verbosity.

#![allow(unused)]
fn main() {
fn setup(mut commands: Commands) {
    commands.spawn(
        button_!(
            "Click me", background_color: "white", padding: px(10);
            on: on_button_click
        )
    );
}

fn on_button_click(_clicked: On<Clicked>) {
    // do something
}
}

Now it’s much cleaner compare to previous example.

Another motivation behind this project is the performance inherent to a modern game engine. Bevy utilizes a decoupled architecture consisting of a Spatial Simulation and an Interface Overlay. This allows for hardware-accelerated rendering of complex 2D/3D meshes and custom shaders within the World Space, while maintaining a highly responsive, independent UI.

Bevy Layers

That means you can render any kind of heavy graphic within your application without sacrifying performance.

Core Features

  • Built-in widgets including button, modal, text input and more.
  • Leverages Bevy’s massive parallelism for smooth and efficient rendering.
  • Routing systems.
  • Custom styling with ID & Classes similar to HTML/CSS.
  • High level API and flexible.

Installation

cargo add makara

Getting Start

IMPORTANT

Makara relies entire on Bevy’s UI system and ECS. When you use Makara, you’re actually using the Bevy engine, with Makara providing an abstraction for its UI system to help you build GUI applications with ease.

A solid understanding of Bevy’s ECS is required. If you’re new to Bevy, I recommend checking out Bevy’s quick start guide.

Getting Start

use makara::prelude::*;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(
        root_!(
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center;
            
            [ text_!("Hello world", font_size: 20.0) ]
        )
    );
}

An overview of ECS

Because this library is powered by a game engine which is based on ECS architecture, it’s a must to understand how ECS works.

ECS stands for Entity-Component-System.

  • Entity: entities are objects you see when you run your application. A button widget is an entity, a text widget is an entity, the list goes on. Each entity consists of one or multiple components.

  • Component: think of component as a tag. It’s used to identify different kind of data. In the context of Makara or Bevy, component is just a rust struct or enum.

    For example, you create a button with background color of red and border color of blue in Makara.

    #![allow(unused)]
    fn main() {
    // create button with background color of red and border color of blue 
    button_!("Click me", background_color: "red", border_color: "blue");
    }

    Under the hood, the button is just an entity that contains these 2 components, BackgroundColor and BorderColor which looks something like this.

    #![allow(unused)]
    fn main() {
    (
        BackgroundColor(Color::srgb(1.0, 0.0, 0.0)),
        BorderColor(Color::srgb(0.0, 1.0, 0.0)),
        // other components
    )
    }

    It’s then up to the Bevy engine to translate it to shading language and render it on your GPU.

  • System: systems are just rust functions that take specific arguments and run at specific schedule. Startup schedule means the functions run only once at the beginning. Update schedule means the functions run every single frame. And of course, there are more schedules than just that.

    fn main() {
      App::new()
          .add_plugins(MakaraPlugin::default())
          .add_systems(Startup, setup)
          .run();
    }
    
    fn setup(mut commands: Commands) {
        // do something
    }

    Here, setup function is a system that take a special argument, in this case Commands. It’s being registered to run at Startup schedule which means it only do something once and that’s it.

Understand Makara hierarchy

Just like any other UI frameworks, you need a starting point for your application. In Makara, you start by spawning a single root widget and add other widgets as children of root except modal.

In above example, when you run the application with cargo run you will see the text “Hello world” appears at the center of the screen.

You can make a widget becomes a child of another widget by using [], we will talk about it later.

Widget Structure

Understanding the structure of widgets in Makara

In Makara, creating a widget can be done by calling its pre-defined macro. A button can be created using button_! and a text can be created using text_!.

Each macro is splitted into 3 parts with specific orders and the orders are:

  1. Properties
  2. Event handlers
  3. Children

Let’s have a look at a few examples.

Button

#![allow(unused)]
fn main() {
button_!(
    "Click me", 
    id: "my-btn", 
    background_color: "blue"; // properties end with ;
    
    on: |clicked: On<Clicked>| { 
        println("Button is clicked"); 
    };
    on: |mouse_over: On<MouseOver>| {
        println!("Mouse is over button");
    }
);

// We don't need ; at the end of properties because we don't have an event handler.
button_!("Click me", border_color: "red");
}

Column

#![allow(unused)]
fn main() {
column_!(
    id: "my-column",
    align_items: AlignItems::Center; // properties end with ;
    
    [
        text_!("Hello world"), 
        text_!("Hello again")
    ]
);

// We don't need ; at the end of properties because we don't have any children.
column_!(id: "my-column", align_items: AlignItems::Center);
}

Scroll

#![allow(unused)]
fn main() {
scroll_!(
    align_items: AlignItems::Center;  // properties end with ;
    
    on: |scrolling; On<Scrolling>| {
        println!("scrolling");
    }; // event handler ends with ;
    
    [
        text_!("Hello world"),
        text_!("Hello mom"),
        text_!("Hello friends")
    ]
);
}

The rules for using widget macro

  1. Always put ; after properties if there are event handlers or children [].
  2. Always put ; after an event handler if there are more event handlers or children [].

Widgets

Makara provides built-in widgets that are needed for building GUI applications including button, modal, text input and more. There will be more important widgets added to Makara in upcoming new versions.

Root

root_! is a starting point for UI hierarchy, so it needs to be spawned by commands.

#![allow(unused)]
fn main() {
commands.spawn(root_!());
}

For more detail, see RootBundle, RootQuery, RootWidget.

Text

#![allow(unused)]
fn main() {
text_!("Hello world");
}

For more detail, see TextBundle, TextQuery, TextWidget.

Button

#![allow(unused)]
fn main() {
button_!("Click me");
}

With event listeners

#![allow(unused)]
fn main() {
button_!(
    "Click me";
    
    on: |clicked: On<Clicked>| {}; 
    on: |over: On<MouseOver>| {}; 
    on: |out: On<MouseOut>| {}; 
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see ButtonBundle, ButtonQuery, ButtonWidget.

Checkbox

#![allow(unused)]
fn main() {
checkbox_!("Check me");
}

With event listeners

#![allow(unused)]
fn main() {
checkbox_!(
    "Check me";
    
    on: |clicked: On<Clicked>| {}; 
    on: |over: On<MouseOver>| {}; 
    on: |out: On<MouseOut>| {}; 
    on: |active: On<Active<String>>| {}; 
    on: |inactive: On<Inactive<String>>| {}; 
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see CheckboxBundle, CheckboxQuery, CheckboxWidget.

Circular

#![allow(unused)]
fn main() {
circular_!();
}

With event listeners

#![allow(unused)]
fn main() {
circular_!(
    on: |over: On<MouseOver>| {}; 
    on: |out: On<MouseOut>| {}; 
    on: |change: On<Change<f32>>| {}; 
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see CircularBundle, CircularQuery, CircularWidget.

Progress Bar

#![allow(unused)]
fn main() {
progress_bar_!();
}

With event listeners

#![allow(unused)]
fn main() {
progress_bar_!(
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |change: On<Change<f32>>| {};
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see ProgressBarBundle, ProgressBarQuery, ProgressBarWidget.

Column

#![allow(unused)]
fn main() {
column_!([
    text_!("Item 1"),
    text_!("Item 2")
]);
}

With event listener

#![allow(unused)]
fn main() {
column_!(
    on: |built: On<WidgetBuilt>| {};
    [ text_!("Item 1") ]
);
}

For more detail, see ColumnBundle, ColumnQuery, ColumnWidget.

Row

#![allow(unused)]
fn main() {
row_!([
    text_!("Left"),
    text_!("Right")
]);
}

With event listener

#![allow(unused)]
fn main() {
row_!(
    on: |built: On<WidgetBuilt>| {};
    [ text_!("Item 1") ]
);
}

For more detail, see RowBundle, RowQuery, RowWidget.

#![allow(unused)]
fn main() {
link_!("https://rust-lang.org/");
}

With event listeners

#![allow(unused)]
fn main() {
link_!(
    "https://rust-lang.org/";

    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see LinkBundle, LinkQuery, LinkWidget.

#![allow(unused)]
fn main() {
dropdown_!(
    "Click me to show option";
    [
        button_!("Sign In"),
        button_!("Sign Up"),
        button_!("About us")
    ]
);
}

With event listeners

#![allow(unused)]
fn main() {
dropdown_!(
    "Click me to show option";

    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {};

    [
        button_!("Sign In"),
        button_!("Sign Up")
    ]
);
}

For more detail, see DropdownBundle, DropdownQuery, DropdownWidget.

Image

#![allow(unused)]
fn main() {
// path or url
image_!("path/to/image.png");
}

With event listeners

#![allow(unused)]
fn main() {
image_!(
    "path/to/image.png";
    
    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {}
);
}

For more detail, see ImageBundle, ImageQuery, ImageWidget.

Modal is independent and doesn’t need to be part of root widget.

#![allow(unused)]
fn main() {
command.spawn(
    modal_!([
        column_!([
            text_!("Hello world"),
            button_!("Close modal")
        ])
    ])
);
}

With event listeners

#![allow(unused)]
fn main() {
modal_!(
    on: |active: On<Active>| {};
    on: |inactive: On<Inactive>| {};
    on: |built: On<WidgetBuilt>| {};

    [
        column_!([
            text_!("Hello world"),
            button_!("Close modal")
        ])
    ]
);
}

For more detail, see ModalBundle, ModalQuery, ModalWidget.

Radio Group & Radio

radio_group needs radio as its item.

#![allow(unused)]
fn main() {
radio_group_!([
    radio_!("Pay by Card"),
    radio_!("Pay by Cash")
]);
}

With event listeners

#![allow(unused)]
fn main() {
radio_group_!(
    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |change: On<Change<String>>| {};
    on: |built: On<WidgetBuilt>| {};

    [
        radio_!(
            "Pay by Card";
            on: |active: On<Active>| {}; 
            on: |inactive: On<Inactive>| {}
        ),
        radio_!("Pay by Cash")
    ]
);
}

For more detail, see RadioGroupBundle, RadioGroupQuery, RadioGroupWidget, RadioBundle, RadioQuery, RadioWidget.

Scroll

#![allow(unused)]
fn main() {
scroll_!([
    text_!("Hello world"),
    text_!("Hello there")
]);
}

With event listener

#![allow(unused)]
fn main() {
scroll_!(
    on: |scrolling: On<Scrolling>| {};
    [
        text_!("Hello world"),
        text_!("Hello there")
    ]
);
}

For more detail, see ScrollBundle, ScrollQuery, ScrollWidget.

Select

#![allow(unused)]
fn main() {
select_!("Click me to show option", choices: &["Cash", "Card", "Afterpay"]);    
}

With event listeners

#![allow(unused)]
fn main() {
select_!(
    "Click me to show option", 
    choices: &["Cash", "Card", "Afterpay"];
    
    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {};
    on: |active: On<Active>| {};
    on: |inactive: On<Inactive>| {};
    on: |change: On<Change<String>>| {}
);
}

For more detail, see SelectBundle, SelectQuery, SelectWidget.

Slider

#![allow(unused)]
fn main() {
// Takes range-start & range-end as arguments.
// In this case start at 0.0, end at 100.0 .
slider_!(min: 0.0, max: 100.0);
}

With event listeners

#![allow(unused)]
fn main() {
slider_!(
    min: 0.0, max: 100.0;
    
    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {}; 
    on: |change: On<Change<f32>>| {}
);
}

For more detail, see SliderBundle, SliderQuery, SliderWidget.

Text Input

#![allow(unused)]
fn main() {
text_input_!("Enter something");
}

With event listeners

#![allow(unused)]
fn main() {
text_input_!(
    "Enter something";
    
    on: |clicked: On<Clicked>| {};
    on: |over: On<MouseOver>| {};
    on: |out: On<MouseOut>| {};
    on: |built: On<WidgetBuilt>| {}; 
    on: |change: On<Change<String>>| {}
);
}

For more detail, see TextInputBundle, TextInputQuery, TextInputWidget.

Querying Widgets

Each widget type has its own query and blueprint. Once widgets are built, you can query any widgets within systems using its corresponding query struct.

For example, let’s say we have a text and a button. Once the button is clicked, we want to update the text.

#![allow(unused)]
fn main() {
column_!([
    text_!("Hello world", id: "my-text"),
    button_!("Click me"; on: |_clicked: On<Clicked>, mut text_q: TextQuery| {
        if let Some(text) = text_q.find_by_id("my-text") {
            text.text.value.0 = "Hello Mom".to_string();
        }
    })
]);
}

In the example above, once the button is clicked, we query text widgets using TextQuery. We then find the specific widget using its id. This is similar to how javascript finds HTML elements within the DOM.

Of course, you can query a widget using class or entity instead of id. Querying with class is useful if you want to fetch multiple widgets at same time, but requires you to write extra code.

#![allow(unused)]
fn main() {
// change all text colors when button is clicked.
column_!([
    text_!("Hello world", class: "my-text"),
    text_!("Hello mom", class: "my-text"),
    text_!("Hello friend", class: "my-text"),
    text_!("Hello stranger", class: "my-text"),
    
    button_!("Click me"; on: |_clicked: On<Clicked>, mut text_q: TextQuery| {
        // find_by_class returns list of entity
        let text_entities = text_q.find_by_class("my-text");
    
        // iterate thru the list
        for entity in text_entities.into_iter() {
            if let Some(text) = text_q.find_by_entity(entity) {
                text.text.color.0 = Color::RED;
            }
        }
    })
]);
}

For more information about widget query and its blueprint, see widgets.

Styling

Styling in Makara

There are 2 ways to provide custom styles to widgets.

  1. Style on widgets directly using properties.
  2. Attach an ID or Classes and provide styles via ID or Classes.

Direct styling

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(
        root_!(
            align_items: AlignItems::Center, // direct styling
            justify_content: JustifyContent::Center; // direct styling
            
            [ text_!("Hello world", font_size: 20.0) ]
        )
    );
}

With ID and Classes

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, (setup, setup_styles)) // add this
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(
        root_!(id: "root"; [
            text_!("Hello world", id: "text-one", class: "text"),
            text_!("Hello mom", class: "text"),
            text_!("Hello friend", class: "text hello-friend"),
            text_!("Hello stranger", class: "text")
        ])
    );
}

fn setup_styles(mut style: ResMut<CustomStyle>) {
    // via id
    style.bind_id(
        "root",
        Style::new()
            .align_items(AlignItems::Center)
            .justify_content(JustifyContent::Center)
    );
    
    // via id
    style.bind_id("text-one", Style::new().color("red"));
    
    // via class
    style.bind_class(
        "text",
        Style::new().font_size(20.0)
    );
    
    // via class
    style.bind_class(
        "hello-friend",
        Style::new().color("blue")
    );
}

When you run the application:

  • root will get AlignItems and JustifyContent styles.
  • all texts will have font_size of 20.0.
  • only one text with id text-one will have color of red.
  • only one text with class hello-friend will have color of blue.

See Style API for more info.

Routing

Routing

Routing in Makara is really easy. There are only 3 steps.

  1. Specifying route name on root widget.
  2. Register route.
  3. Navigating.

Specifying route

Just like I mentioned in previous chapter about hierarchy, we need to use root widget as a starting point for UI hierarchy.

If you want to have multiple pages or views, you need to create multiple starting points. In other words, each root defines one page or view.

For example, let’s say you want to have Home view and About Us view.

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, (setup_home_view, setup_about_us_view))
        .run();
}

fn setup_home_view(mut commands: Commands) {
    commands.spawn(
        root_!(route: "home"; [ text_!("This is home view") ])
    );
}

fn setup_about_us_view(mut commands: Commands) {
    commands.spawn(
        root_!(route: "about-us"; [ text_!("This is about us view") ])
    );
}

We can specify route name for each view by using route property.

Registering Routes

After we defined route on each view, we need to register those routes. We can do so by using Router resource.

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, (
            setup_home_view, 
            setup_about_us_view, 
            setup_routes // add here
        ))
        .run();
}

fn setup_routes(mut router: ResMut<Router>) {
    router.register_routes(["home", "about-us"]);
    
    // define which route you want to see when the application started
    router.default_route("home", ());
}

// setup view systems..

You may also want to set the default route. If you don’t, the first route in the list will be set to default automatically.

Route Navigation

Let’s update the above examples to demonstrate how to nagivate to specific route.

#![allow(unused)]
fn main() {
fn setup_home_view(mut commands: Commands) {
    commands.spawn(
        root_!(route: "home"; [
            text_!("This is home view"),
            button_!(
                "Go to about us view";
                on: |_clicked: On<Clicked>, mut router: ResMut<Router>| {
                    router.nagivate("about-us", ());
                }
            )
        ])
    );
}

fn setup_about_us_view(mut commands: Commands) {
    commands.spawn(
        root_!(route: "about-us"; [
            text_!("This is about us view"),
            button_!(
                "Go to home view";
                on: |_clicked: On<Clicked>, mut router: ResMut<Router>| {
                    router.nagivate("home", ());
                }
            )
        ])
    );
}
}

We can navigate to any route by calling navigate method.

Route Params

Of course, each route can have one or multiple params.

For example, let’s say we have a view called product-detail, but this view is dynamic based on product id or name.

#![allow(unused)]
fn main() {
fn setup_home_view(mut commands: Commands) {
    commands.spawn(
        root_!(route: "home"; [
            text_!("This is home view"),
            button_!(
                "See product";
                on: |_clicked: On<Clicked>, mut router: ResMut<Router>| {
                    router.nagivate(
                        "product-detail", 
                        Param::new()
                            .value("id", "1234") // just random product id
                            .value("name", "Iphone 17 256GB") // product name
                    );
                }
            )
        ])
    );
}

fn setup_product_detail_view(mut commands: Commands) { 
    commands.spawn(
        root_!(
            route: "product-detail";
            
            on: |page_loaded: On<PageLoaded>, mut text_q: TextQuery| {
                if let Some(text) = text_q.find_by_id("product-name") {
                    if let Some(name) = page_loaded.param.get("name") { 
                        text.text.value.0 = name.clone();
                    } 
                }
            };
            
            [ text_!("Product name: Unknown", id: "product-name") ]
        )
    );
}
}

See Router and Param for more info.

Organizing your project

Organizing your project

UI hierarchy can become verbosity and hard to keep track of. It’s always recommended to seperate your code into snippets.

For example,

use makara::prelude::*;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(MakaraPlugin::default())
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {    
    commands.spawn(
        root_!(
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center;
            
            [
                text_!("Register your info"),
                input_container(),
                button_container()
            ]
        )
    );
}

fn input_container() -> impl Bundle {
    column_!([
        text_input_!("Enter email"),
        text_input_!("Enter name"),
        text_!("Choose gender"),
        radio_group_!([ radio_!("Male"), radio_!("Female") ])
    ])
}

fn button_container() -> impl Bundle {
    row_!([
        button_!("Cancel"; on: |_clicked: On<Clicked>| {}),
        button_!("Submit"; on: |_clicked: On<Clicked>| {})
    ])
}