Introduction

Cobweb is a UI and asset-management framework for the bevy game engine.

Cobweb Features

  • Custom scene format called COB
  • Localization framework (text, fonts, images, audio)
  • Font family API
  • Built-in UI widgets and color palettes
  • Asset management tools
  • And many quality of life features.

This book is intended to give you a starting point for making your own UI.

Structure of the book

We will start off with the most basic example possible. From there the book will split off into recipes that you can browse as you need them for your own projects.

The final section will cover some common errors.

Getting started

Commands

let's make an empty project to test it out:

cargo new cobweb_test
cd cobweb_test
cargo add bevy

This book won't be making any distinction between bevy_cobweb and bevy_cobweb_ui. bevy_cobweb is a reactivity library that bevy_cobweb_ui uses for convenience methods like .on_pressed.

cargo add bevy_cobweb
cargo add bevy_cobweb_ui -F hot_reload

We are definitely adding hot reloading, but you can remove it for your release version.

Syntax Highlighting

You can optionally install syntax highlighting for the cob files we will be using.

Instructions here.

Rust Code

Set your main.rs to be as below.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root().spawn_scene_simple(("main.cob", "main_scene"), &mut s);
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

This will add systems to handle loading of your files and other plumbing: .add_plugins(CobwebUiPlugin)

This tells cobweb to load this cob file: .load("main.cob")

When all the cob files are loaded this will call our UI setup system: .add_systems(OnEnter(LoadState::Done), build_ui)

This makes a new UI hierarchy with its own root node: c.ui_root().spawn_scene_simple(("main.cob", "main_scene"), &mut s);

COB code

Create a new folder called assets.

Create a new file called main.cob.

Add in the following:

#scenes
"main_scene"
    TextLine{ text: "Hello, World!" }

Now let's run the program.

We have our first cobweb UI program. Next chapter we can start making changes without recompiling.

Cob files

Cob allows you to separate your UI from your code, in addition to enabling hot reloading of changes. Cob is whitespace-sensitive.

Sections

Cob files are made up of sections. For now, we will explore one type of section.

Scenes

Scenes are declared following the #scenes keyword. Scenes are given a name. In our example, we use "main_scene"... extremely creative. We use scene root nodes to load scenes Cobweb. We will need to recompile to add new scenes to our app.

Extending our example.

First, rerun the program if you have closed it. We want to see the hot reloading in action.

Let's change our cob file to be something like this:

#scenes
"main_scene"
    TextLine{ text: "Hello, World!, I am writing using cobweb " }

Loadables

Loadables are Rust types that can be added to the scene nodes. For now, we will only explore one type of loadable:

Types that implement the instruction trait.

TextLine is an example of a loadable.

Loadables should not have space between their name and the opening {.

let's add another called AbsoluteNode:

#scenes
"main_scene"
    AbsoluteNode{left:40% top:30vh}
    TextLine{ text: "Hello, World!, I am writing using cobweb" }

We have now moved the text around. You can also experiment with other units such as 40px 40vw.

Separate fields are not comma separated.

let's try another loadable BackgroundColor:

#scenes
"main_scene"
    AbsoluteNode{left:40%}
    BackgroundColor(#FFFF00)
    TextLine{ text: "Hello, World!, I am writing using cobweb " }

We can see this does not contrast well with the text without recompiling!

Hex values convert to Srgba colours.

Animations

Let's add hovering effects. We will start off by changing the background colours based on user hovering.

Hovering and pressing
#scenes
"main_scene"
    AbsoluteNode{left:40%}
    TextLine{ text: "Hello, World!, I am writing using cobweb " }
    
    Animated<BackgroundColor>{
        idle:#FF0000 // You can also input colours in other formats
        hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
        press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
    }

Animated is a loadable that works with implementers of the AnimatableAttribute trait. One example implementer is BackgroundColor.

Next

Next we will look at how we know what loadables we can use based on the documentation, and what fields are available in each loadable.

Loadables and their fields

So far you have just been given the types to modify. This page aims to explain the different data types in cob files. When you read the documentation of each loadable hopefully you will be able to easily implement it.

Rerun your program if you have closed it.

Node shadow

let's start with adding a NodeShadow to our example. The documentation can be found here.

At the time of writing it looked like this:

pub struct NodeShadow {
    pub color: Color,
    pub x_offset: Val,
    pub y_offset: Val,
    pub spread_radius: Val,
    pub blur_radius: Val,
}

Let's start with Val before color as it is slightly simpler.

Val

Val variants can be written with special units (px, %, vw, vh, vmin, vmax) and the keyword auto. For example, 10px is equivalent to Px(10).

Colour

In our previous examples we have loaded colour using both RGB and HSLA.

Hex

Hex colours are a special data type in cob and can just be written as #FF00FF with an implied alpha of FF. You can also add an explicit alpha by adding in extra digits #FF00FFEE.

NewType collapsing

Loadable newtypes and newtype enum variants use newtype collapsing to simplify what you write. Newtypes are collapsed by discarding 'outer layers'.

An example we have used for HSLA. This is written in rust:

Color::Hsla(Hsla {
    hue: 240.0,
    saturation:1.0,
    lightness: 0.5,
    alpha:1.0,
})

And this is written in cob:

Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }

The rule for enums inside loadables is A::B(C{ .. }) -> B{ .. }. Newtype collapsing can occur for loadables like A(vec![]) -> A[].

Defaults

Not all fields in loadables need to be filled out. Every field left blank we will be defaulted. Only fields annotated with #[reflect(default)] can be skipped.

Adding NodeShadow

With the above information we have enough to create our node shadow:

#scenes
"main_scene"
    AbsoluteNode{left:40%}
    TextLine{ text: "Hello, World!, I am writing using cobweb " }
    NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px} // <-- our new node shadow
    Animated<BackgroundColor>{
        idle:#FF0000 // You can also input colours in other formats
        hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
        press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
    }
    

Done

Floats

Floats are written similar to how they are written in rust.

  • Scientific notation: 1.2e3 or 1.2E3.
  • Integer-to-float conversion: 1 can be written instead of 1.0.
  • Keywords inf/-inf/nan: infinity, negative infinity, NaN.

Let's go ahead with an example using size in TextLine:

#scenes
"main_scene"
    AbsoluteNode{left:40%}
    TextLine{ text: "Hello, World!, I am writing using cobweb " size:150 } // <-- add the size here
    NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
    Animated<BackgroundColor>{
        idle:#FF0000 // You can input colours in other formats
        hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
        press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
    }

Strings

Strings are handled similar to how rust string literals are handled.

  • Enclosed by double quotes (e.g. "Hello, World!").
  • Escape sequences: standard ASCII escape sequences are supported (\n, \t, \r, \f, \", \\), in addition to Unicode code points (\u{..1-6 digit hex..}).
  • Multi-line strings: a string segment that ends in \ followed by a newline will be concatenated with the next non-space character on the next line.
  • Can contain raw Unicode characters.

Using rust to update text

We will be using rust to modify the contents of a text node at runtime (no hot reloading).

Modify the cob file

Let's setup the cob file as below.

"scene"
    AbsoluteNode{left:40%}
    "cell"
        Animated<BackgroundColor>{
            idle:#FF0000 // You can input colours in other formats
            hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
            press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
        }
        NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
        "text"
            TextLine{text:"Hello, World!, I am writing using cobweb "} // <-- will be overwritten

We split position logic into a child node called cell which holds most of the positioning and styling logic. cell has a child called text. Text is a minimal node responsible for just text stuff.

Why we split text from styling

This is more of an html/CSS pattern then anything particular with cobweb but it is worth mentioning here.

It just turns out to be easier to position nodes than it is to position text.

Rust

Updating text at runtime

Let's change the rust code to be as below.

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");
        });
}

We have changed spawn_scene_simple to spawn_scene.

When we load "main_scene" from the cob file, we automatically load all the child nodes recursively. The second argument is a closure where we can use scene_handle, which is similar to commands and has extension methods provided by cobweb.

Inside the closure we call get(cell::text) which is basically a path syntax to go straight to the text node. It also possible to call edit on "cell" then call update_text inside the resulting closure.

Recompile and run the program. You will see your text has changed to reflect the rust code.

Spawning new nodes

Cobweb can also spawn new scenes inside other scenes. Let's start with an example.

Below we have our new scene called number_text.

If the concept of scenes was a bit confusing before, this should clarify it a bit more.

#scenes
"scene"
    AbsoluteNode{left:40% flex_direction:Column}
    "cell"
        Animated<BackgroundColor>{
            idle:#FF0000 // You can input colours in other formats
            hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
            press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
        }
        NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
        "text"
            TextLine{text:"Hello, World!, I am writing using cobweb "}


"number_text"
    "cell"
        "text"
            TextLine{text:"placeholder"}

Now let's change our rust code to spawn some scenes.

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            // Spawning new ui nodes inside our main scene
            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.get("cell::text").update_text(i.to_string());
                });
            }
        });
}

We now have some numbers that appear based on your code. We can still modify the cob files and change styling:

"number_text"
    "cell"
        "text"
            TextLine{text:"placeholder"}
            TextLineColor(Hsla{hue:45 saturation:1.0 lightness:0.5 alpha:1.0}) // <-- add this

Making nodes interactive

Setting our UI to react to the user is essential, and easy. Here we add on_pressed for our "number_text" node:

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move |/* We can write arbitrary bevy parameters here*/|{
                            println!("You clicked {}", i);
                        });
                    });
                });
            }
        });
}

Custom marker component

In bevy it's common to have marker components. That is, components with no data that mark the entity as having some user-defined purpose. There are at least two ways to do this.

Before looking at either approach we should set some goals.

Goals:

  • Add an exit button to the interface.
  • Add a button to despawn the interface.
  • On despawning add another button to respawn the interface.

Here is our code that we've built so far, adding in a MainInterface marker component.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

#[derive(Component)]
struct MainInterface;

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move|/* We write arbitary bevy parameters here*/|{
                            println!("You clicked {}", i);
                        });
                    });
                });
            }
        });
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

And here is the cob file we've built, adding in some small button scenes:

#scenes
"main_scene"
    AbsoluteNode{left:40% flex_direction:Column}
    "cell"
        Animated<BackgroundColor>{
            idle:#FF0000
            hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
            press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
        }
        NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
        "text"
            TextLine{text:"Hello, World!, I am writing using cobweb "}


"number_text"
    "cell"
        "text"
            TextLine{text:"placeholder"}
            TextLineColor(Hsla{hue:45 saturation:1.0 lightness:0.5 alpha:1.0})


"exit_button"
    TextLine{text:"Exit"}
"despawn_button"
    TextLine{text:"Despawn"}
"respawn_button"
    TextLine{text:"Respawn"}

The exit and despawn buttons could just as easily be added as children of main_scene.

Rust approach

Let's look at the first way of doing this using what is likely to be a more familiar approach to you.

#[derive(Component)]
struct MainInterface;

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle.insert(MainInterface); // <-- add the marker component
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move|/* We write arbitary bevy parameters here*/|{
                            println!("You clicked {}", i);
                        });
                    });
                });
            }
        });
}

Now let's add the despawning button. We can use on_pressed along with a normal bevy query.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

#[derive(Component)]
struct MainInterface;

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle.insert(MainInterface);
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move|/* We write arbitary bevy parameters here*/|{
                            println!("You clicked {}",i);
                        });
                    });
                });
            }

            // NEW: despawn button
            scene_handle.spawn_scene(("main.cob", "despawn_button"), |scene_handle| {
                // Despawn the main interface on press.
                // Notice this looks like a normal bevy query.
                scene_handle.on_pressed(
                    |interface_query: Query<Entity, With<MainInterface>>,
                     mut commands: Commands| {
                        // Cobweb callbacks can use `?` if you return `OK` or `DONE`.
                        // The .result() converts Options to Results.
                        commands
                            .get_entity(interface_query.get_single()?)
                            .result()?
                            .despawn_recursive();
                        OK
                    },
                );
            });
        });
}

Now let's add the exit button, which will not by any more difficult:

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

#[derive(Component)]
struct MainInterface;

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle.insert(MainInterface);
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move|/* We write arbitary bevy parameters here*/|{
                            println!("You clicked {}", i);
                        });
                    });
                });
            }
            scene_handle.spawn_scene(("main.cob", "despawn_button"), |scene_handle| {
                scene_handle.on_pressed(
                    |interface_query: Query<Entity, With<MainInterface>>,
                     mut commands: Commands| {
                        commands
                            .get_entity(interface_query.get_single()?)
                            .result()?
                            .despawn_recursive();
                        OK
                    },
                );
            });

            // NEW: exit button
            scene_handle.spawn_scene(("main.cob", "exit_button"), |scene_handle| {
                scene_handle.on_pressed(
                    |mut commands: Commands, focused_windows: Query<Entity, With<Window>>| {
                        let window = focused_windows.get_single()?;
                        commands.get_entity(window).result()?.despawn();
                        OK
                    },
                );
            });
        });
}

Spawn new interface

Let's add a system for making respawn buttons. The respawn button will spawn the main interface.

fn spawn_respawn_button(mut c: Commands, mut s: SceneBuilder) {
    c.ui_root()
        .spawn_scene(("main.cob", "respawn_button"), &mut s, |scene_handle| {
            //TODO respawning main interface
        });
}

Now call it on despawn

            scene_handle.spawn_scene(("main.cob", "despawn_button"), |scene_handle| {
                scene_handle.on_pressed(
                    |interface_query: Query<Entity, With<MainInterface>>,
                     mut commands: Commands| {
                        commands
                            .get_entity(interface_query.get_single()?)
                            .result()?
                            .despawn_recursive();
                        // NEW: spawn the respawn button
                        commands.run_system_cached(spawn_respawn_button);
                    },
                );
            });

run_system_cached is a convenient way to call a function arbitrarily when you don't need to supply data. Consider observers if you do. You can also use syscall from bevy_cobweb.

We can now despawn the main interface. Another approach could have been writing run_system_cached as an observer, and sending an event to trigger it in our "despawn_button" callback.

All there is to do now is fill in the respawn logic. There is not much to discuss so will see the updated code below.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

#[derive(Component)]
struct MainInterface;

fn build_ui(mut c: Commands) {
    c.spawn(Camera2d);
    c.run_system_cached(spawn_main_interface);
}

fn spawn_main_interface(mut c: Commands, mut s: SceneBuilder) {
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            scene_handle.insert(MainInterface);
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            for i in (0..=10).into_iter() {
                scene_handle.spawn_scene(("main.cob", "number_text"), |scene_handle| {
                    scene_handle.edit("cell::text", |scene_handle| {
                        scene_handle.update_text(i.to_string());
                        scene_handle.on_pressed(move|/* We write arbitary bevy parameters here*/|{
                            println!("You clicked {}",i);
                        });
                    });
                });
            }
            scene_handle.spawn_scene(("main.cob", "despawn_button"), |scene_handle| {
                scene_handle.on_pressed(
                    |interface_query: Query<Entity, With<MainInterface>>,
                     mut commands: Commands| {
                        commands
                            .get_entity(interface_query.get_single()?)
                            .result()?
                            .despawn_recursive();
                        commands.run_system_cached(spawn_other_interface);
                        OK
                    },
                );
            });
            scene_handle.spawn_scene(("main.cob", "exit_button"), |scene_handle| {
                scene_handle.on_pressed(
                    |mut commands: Commands, focused_windows: Query<Entity, With<Window>>| {
                        let window = focused_windows.get_single()?;
                        commands.get_entity(window).result()?.despawn();
                        OK
                    },
                );
            });
        });
}

fn spawn_respawn_button(mut c: Commands, mut s: SceneBuilder) {
    c.ui_root()
        .spawn_scene(("main.cob", "respawn_button"), &mut s, |scene_handle| {
            let entity = scene_handle.id();
            scene_handle.on_pressed(move |mut commands: Commands| {
                commands.get_entity(entity).result()?.despawn_recursive();
                commands.run_system_cached(spawn_main_interface);
                OK
            });
        });
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

To despawn the current scene we can get the entity using .id().

Moving MainInterface to a cob file

Now let's try out another way to add marker components via cob files.

We need to add some derives:


#[derive(Component, Default, PartialEq, Reflect)]
struct MainInterface;

Now let's remove the .insert(MainInterface) and register the component type.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

#[derive(Component, Default, PartialEq, Reflect)]
struct MainInterface;

fn build_ui(mut c: Commands) {
    c.spawn(Camera2d);
    c.run_system_cached(spawn_main_interface);
}

fn spawn_main_interface(mut c: Commands, mut s: SceneBuilder) {
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // <-- We no longer have insert here
            scene_handle
                .get("cell::text")
                .update_text("My runtime text");

            // ...
        });
}

fn spawn_respawn_button(mut c: Commands, mut s: SceneBuilder) {
    // ...
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .register_component_type::<MainInterface>() // <-- This allows cob to load this type
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

Let's now add MainInterface to our cob file.

#scenes
"main_scene"
    MainInterface // <-- NEW
    AbsoluteNode{left:40%,flex_direction:Column}
    "cell"
        Animated<BackgroundColor>{
            idle:#FF0000
            hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
            press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
        }
        NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
        "text"
            TextLine{text:"Hello, World!, I am writing using cobweb "}


"number_text"
    "cell"
        "text"
            TextLine{text:"placeholder"}
            TextLineColor(Hsla{hue:45 saturation:1.0 lightness:0.5 alpha:1.0})


"exit_button"
    TextLine{text:"Exit"}
"despawn_button"
    TextLine{text:"Despawn"}
"respawn_button"
    TextLine{text:"Respawn"}

We are now done.

Cob file organization

#defs section

Cob files can have constants that let you define common values to be used all over your cob files. This will allow you to make changes in one place to impact multiple nodes.

Let's do an example.

// #defs is another type of section. So far we have only been doing #scenes.
#defs
$text_colour = Hsla{hue:45 saturation:1.0 lightness:0.5 alpha:1.0}

#scenes
"main_scene"
    MainInterface
    AbsoluteNode{left:40% flex_direction:Column}
    "cell"
        Animated<BackgroundColor>{
            idle:#FF0000
            hover:Hsla{ hue:120 saturation:1.0 lightness:0.50 alpha:1.0 }
            press:Hsla{ hue:240 saturation:1.0 lightness:0.50 alpha:1.0 }
        }
        NodeShadow{color:#FF0000 spread_radius:10px blur_radius:5px}
        "text"
            TextLine{text:"Hello, World!, I am writing using cobweb "}


"number_text"
    "cell"
        "text"
            TextLine{text:"placeholder"}
            TextLineColor($text_colour) // <-- now uses a constant


"exit_button"
    TextLine{text:"Exit"}
    TextLineColor($text_colour) // <-- now uses a constant
"despawn_button"
    TextLine{text:"Despawn"}
    TextLineColor($text_colour) // <-- now uses a constant
"respawn_button"
    TextLine{text:"Respawn"}
    TextLineColor($text_colour) // <-- now uses a constant

Defs also include scene macros. TODO

#manifest and #import

You can further extend this over many files using #manifest and #import sections.

#manifest

Manifests let you load many files recursively. This way you only need to write .load("manifest.cob") once in your app.

Make a central manifest file at assets/manifest.cob:

#manifest
"main.cob" as main
"ui/colour_scheme.cob" as cs

The as main defines a manifest key for the file main.cob.

Now replace the .load("main.cob") in your app with .load("manifest.cob"). You can also simplify .spawn_scene(("main.cob", "main_scene"), ...) to .spawn_scene(("main", "main_scene"), ...), using the main file's manifest key.

#import

Imports let you bring in #defs from other files.

In any file where you want to import defs you can do as below.

#import
cs as colours

Defs from cs are scoped to the import alias colours:

BackgroundColor($colours::background_colour)

Quick notes

This section will mention notes that did not come up in the tutorial but may still be helpful for you.

Misc Cob Syntax

Splat

splat is a shorthand way for multiple fields to be set the same value, you may have already encountered this in bevy with Vec2::splat or Vec3::splat.

To use splat the data type should implement splattable.

For example Splat<Border>(15px)

This should not be defined in a FlexNode or AbsoluteNode but as a seperate line.

Loading Cob files

When you have a cob file that defines scenes, you must register with the app by using this syntax (or add it to a manifest section of another file): .load("main.cob")

Waiting for LoadState::Done

If your UI is to appear at the start of the game then you should wait to ensure cobweb has had a chance to read its scene data. .add_systems(OnEnter(LoadState::Done), build_ui)

If your UI comes up in response to player actions on other UI, then calling it using observers or events should be fine as cob files would have been already read by cobweb.

Other features not covered (at least for now!)

Cobweb has reactive features.

Some examples can be found here.

You can use broadcasts to refresh your ui, .reactor(broadcast::<MyArbitaryStruct>(), |/*bevy query*/| { }); Send the event using commands.react().broadcast(MyArbitaryStruct).

There are others as well.

Other features

  • Radio button widget: we hope to have an example later. It can also be useful for tabbing.
  • Here's a good starting point for animations, states, interactions.
  • Localization: there is an example here, with documentation here.
  • Commands: cob files include a #commands section. TODO

Cob documentation.

You can find more details about cob files here.

Pulling existing node to edit

For creating nodes from scratch we used commands.ui_builder(UiRoot) (or commands.ui_root()). To modify an existing UI node, we can use the ui_builder extension with the entity that will be the parent of the newly-spawned scene: commands.ui_builder(parent_entity).spawn_scene_simple(..).

If you use commands.get you can end up with this error:

WARN bevy_ui::layout: Node (233769v8) is in a non-UI entity hierarchy. You are using an entity with UI components as a child of an entity without UI components, your UI layout may be broken. at /home/lyndonm/.cargo/registry/src/index.crates.io-6f17d22bba15001f/bevy_ui-0.15.0-rc.3/src/layout/mod.rs:267

Tabs

Goals

  • Create a window with tabs that can be selected from.
  • Learn about radio buttons.
  • Learn about ControlRoot and ControlMembers for multi-entity interactions and states.

Method

Tabs are pretty much radio buttons, so we will be using these to implement them in cobweb_ui. We will start with a basic window that should approximate what this book has taught so far.

Starting rust code.

Hopefully this is familiar from previous chapters.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {});
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

Starting COB file

GridNode and AbsoluteGridNode are used to create an orderly scaled layout.

FlexNode is used to center the text on some grid column titles.

Note we could add abstractions to tidy up the code, but we will stick to simplicity at the expense of verbosity. Once we have the functionality sorted we look at some abstractions.

Hopefully this code is also mostly familiar from previous chapters.

#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh   }
    BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 })
    "title"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "Tabs education"}
            TextLineColor(Hsla{ hue:60 saturation:0.55 lightness:0.55 alpha:1.0 })

    //Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        "info"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "Info" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })
            
        "exit"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "Exit button"}
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })

    //This is what changes based on menu selection
    "tab_content"
        GridNode{ height:25vh }
        BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 })

    "footer_content"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "I don't change" }
            TextLineColor(Hsla{hue:0 saturation:0.00 lightness:0.85 alpha:1.0})
        

// Tab content that will be spawned at runtime

"info_tab"
    FlexNode{justify_main:Center}
    TextLine{text:"You are in the info tab"}

"exit_tab"
    //Not implemented
    FlexNode{justify_main:Center}
    TextLine{text:"Click me to quit"}

Filling in tab_content

Let's run this and see our ui.

  • Our starting code has an empty lightly coloured square box where our tab content should go.
  • Two buttons are above the square box. These will be our tab menus.
  • A title and footer are there to serve as normal constant values regardless of tab.

First thing to do now is spawn info_tab when info is clicked.

    //Snip
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place in our scene
            let tab_content_entity = scene_handle.get("tab_content").id();
            scene_handle.edit("tab_menu::info", |scene_handle| {
                scene_handle.on_pressed(move |mut c: Commands, mut s: SceneBuilder| {
                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "info_tab"), &mut s);
                });
            });
        });

Now lets do the same for the exit tab.

        //Snip    
        c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place in our scene
            let tab_content_entity = scene_handle.get("tab_content").id();
            scene_handle.edit("tab_menu::info", |scene_handle| {
                scene_handle.on_pressed(move |mut c: Commands, mut s: SceneBuilder| {
                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "info_tab"), &mut s);
                });
            });
            // handling exit tab
            scene_handle.edit("tab_menu::exit", |scene_handle| {
                scene_handle.on_pressed(move |mut c: Commands, mut s: SceneBuilder| {
                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "exit_tab"), &mut s);
                });
            });
        });

We also need to clear the tab contents upon selecting a new tab. Note that we use ? syntax and end the closure with DONE.

    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place in our scene
            let tab_content_entity = scene_handle.get("tab_content").id();
            scene_handle.edit("tab_menu::info", |scene_handle| {
                scene_handle.on_pressed(move |mut c: Commands, mut s: SceneBuilder| {
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "info_tab"), &mut s);
                    DONE
                });
            });
            // handling exit tab
            scene_handle.edit("tab_menu::exit", |scene_handle| {
                scene_handle.on_pressed(move |mut c: Commands, mut s: SceneBuilder| {
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "exit_tab"), &mut s);
                    DONE
                });
            });
        });

Ok we got it working, but its not really fun or intuitive. We still have some problems to solve.

  • The tab selection doesn't communicate which tab is active.
  • It should start with one tab selected.

We will start with communication.

Styling our tab headers

Initial Cob Changes

The plan is to distinguish between selected and deselected tabs by changing the background colour.

To do this we need to use radio buttons.

Adding this as below.

    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup //<--- Sets up state for a group of RadioButtons
        "info"
            RadioButton //<--- New Radio Button
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "Info" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })
            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton //<---- New Radio Button
            "text"
                TextLine{ text: "Exit button" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })

These changes by themselves will not have any noticable impact. However, one thing we should change is switching from on_pressed to on_select since the radio button widget will be selecting tabs for us on press.

    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place in our scene
            let tab_content_entity = scene_handle.get("tab_content").id();
            scene_handle.edit("tab_menu::info", |scene_handle| {
                scene_handle.on_select(move |mut c: Commands, mut s: SceneBuilder| { //<--- now on_select
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "info_tab"), &mut s);
                    DONE
                });
            });
            // handling exit tab
            scene_handle.edit("tab_menu::exit", |scene_handle| {
                scene_handle.on_select(move |mut c: Commands, mut s: SceneBuilder| { //<--- now on_select
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "exit_tab"), &mut s);
                    DONE
                });
            });
        });

Next we will use pseudo states provided by the radio button widget to show tab selection.

Next COB Changes

Lets start with the code to add colours.

    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            // New colour logic
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            "text"
                TextLine{ text: "Info" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })
            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton
            // New colour logic
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            "text"
                TextLine{ text: "Exit button" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })

Let's explain this code.

Animated<BackgroundColor> allows you to define values based on the three css interaction modes idle/hover/press.

By encapsulating with Multi we can specify multiple animation sets tied to the entity's pseudostates.

Multi<Animated<BackgroundColor>>[
    {
        idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
    }
    {
        state: [Selected]
        idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
    }
]  

Now when our tabs change, we know which tab we have open!

Further styling, and sharing state

I have added some more stying without incident.

#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh   }
    BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 })
    "title"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "Tabs education" }
            TextLineColor(Hsla{ hue:60 saturation:0.55 lightness:0.55 alpha:1.0 })

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  

            "text"
                TextLine{ text: "Info" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })
            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla { hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla { hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  
            "text"
                TextLine{ text: "Exit button" }
                TextLineColor(Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 })

    // This is what changes based on menu selection
    "tab_content"
        GridNode{ height:25vh }
        BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 })

    "footer_content"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "I don't change" }
                TextLineColor(Hsla{hue:0 saturation:0.00 lightness:0.85 alpha:1.0})
        

// Tab content that will be spawned at runtime

"info_tab"
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"You are in the info tab"}

"exit_tab"
    // Not implemented
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"Click me to quit"}

Next bit of styling I want to add is text changing colour on hover.

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  

            "text"
                TextLine{ text: "Info" }
                // New Text Colour logic
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle: #FFFF00
                    }
                ]  

            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  
            "text"
                TextLine{ text: "Exit button" }
                // New Text Colour logic
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle:#FFFF00
                    }
                ]  

We have a problem. The text only changes colour when you hover on the text directly, but we want it change when any part of the button is hovered.

This is a completely unforseen situation that I did not just invent for an example!

ControlGroup

ControlMember lets nodes respond to state information from the ControlRoot.

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            ControlRoot //<--- Shares hover state
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  

            "text"
                TextLine{ text: "Info" }
                ControlMember //<--- reads hover state
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle: #FFFF00
                    }
                ]  

            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton 
            ControlRoot //<--- Shares hover state
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  
            "text"
                ControlMember //<--- reads hover state
                TextLine{ text: "Exit button" }
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle:#FFFF00
                    }
                ]  

Abstractions: default selection and cleanup

Next chapter we will introduce new abstractions to make our COB code more managable.

We will also handle selecting the starting tab.

Cleanup

COB cleanup

Our cob file from last chapter looks like below.

It serves it's purpose but we can make this more maintainable.

Ideally we would have done it at the start but the choice was made to focus on the basic concepts.

#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh}
    BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 })
    "title"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "Tabs education" }
            TextLineColor(Hsla{ hue:60 saturation:0.55 lightness:0.55 alpha:1.0 })

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            ControlRoot
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color:Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color:Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  

            "text"
                TextLine{ text: "Info" }
                ControlMember
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle: #FFFF00
                    }
                ]  

            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton
            ControlRoot
            Multi<Animated<BackgroundColor>>[
                {
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
                    hover: Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }
                }
                {
                    state: [Selected]
                    idle: Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                }
            ]  
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color:Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color:Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  
            "text"
                ControlMember
                TextLine{ text: "Exit button" }
                Multi<Animated<TextLineColor>>[
                    {
                        idle: Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
                        hover: Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }
                    }
                    {
                        state: [Selected]
                        idle: #FFFF00
                    }
                ]  

    // This is what changes based on menu selection
    "tab_content"
        GridNode{ height:25vh }
        BackgroundColor(Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 })

    "footer_content"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "I don't change" }
                TextLineColor(Hsla{hue:0 saturation:0.00 lightness:0.85 alpha:1.0})
        


// Tab content that will be spawned at runtime

"info_tab"
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"You are in the info tab"}

"exit_tab"
    // Not implemented
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"Click me to quit"}

#defs

We should start with defining colour names as we repeat many colours that are related.

At the top of the file.

#defs
$window_colour = Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }


#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh   }
    BackgroundColor($window_colour)
    //snip

We will be repeating this pattern.

I postfixed all the names with colour to disambiguate from other data we could add here like widths.

#defs
$window_colour = Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
$title_text_colour = Hsla{ hue:60 saturation:0.55 lightness:0.55 alpha:1.0 }

$tab_background_colour = Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }

$tab_selected_background_colour = Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }

$tab_hover_colour = Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }

$box_shadow_colour = Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }

$text_line_idle_colour = Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
$text_line_hover_colour =  Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }

$text_selected_colour = #FFFF00

$footer_text_colour = Hsla{hue:0 saturation:0.00 lightness:0.85 alpha:1.0}

#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh   }
    BackgroundColor($window_colour)
    "title"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "Tabs education" }
            TextLineColor($title_text_colour)

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            RadioButton
            FlexNode{justify_main:Center}
            ControlRoot
            Multi<Animated<BackgroundColor>>[
                {
                    idle: $tab_background_colour
                    hover: $tab_hover_colour
                }
                {
                    state: [Selected]
                    idle: $tab_selected_background_colour
                }
            ]
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color:$box_shadow_colour
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color:$box_shadow_colour
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  

            "text"
                TextLine{ text: "Info" }
                ControlMember
                Multi<Animated<TextLineColor>>[
                    {
                        idle: $text_line_idle_colour
                        hover: $text_line_hover_colour
                    }
                    {
                        state: [Selected]
                        idle: $text_selected_colour
                    }
                ]  

            
        "exit"
            FlexNode{justify_main:Center}
            RadioButton
            ControlRoot
            Multi<Animated<BackgroundColor>>[
                {
                    idle: $tab_background_colour
                    hover: $tab_hover_colour
                }
                {
                    state: [Selected]
                    idle: $tab_selected_background_colour
                }
            ]  
            Multi<Animated<NodeShadow>>[
                {
                    idle: {
                        color:$box_shadow_colour
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 1px,
                        blur_radius: 1px,
                    }
                }
                {
                    state: [Selected]
                    idle: {
                        color:$box_shadow_colour
                        x_offset: 0px,
                        y_offset: 0px,
                        spread_radius: 10px,
                        blur_radius: 10px,
                    }
                }
            ]  
            "text"
                ControlMember
                TextLine{ text: "Exit button" }
                Multi<Animated<TextLineColor>>[
                    {
                        idle: $text_line_idle_colour
                        hover: $text_line_hover_colour
                    }
                    {
                        state: [Selected]
                        idle: $text_selected_colour
                    }
                ]  

    // This is what changes based on menu selection
    "tab_content"
        GridNode{ height:25vh }
        BackgroundColor($tab_selected_background_colour)

    "footer_content"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "I don't change" }
                TextLineColor($footer_text_colour)
        


// Tab content that will be spawned at runtime

"info_tab"
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"You are in the info tab"}
"calm_tab" // <-- We will be adding this below

"exit_tab"
    // Not implemented
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"Click me to quit"}

Scene macros

Our two tab titles have an almost identical structure that we can share using a scene macro.

Changing the template is as simple as overwriting parts of its structure.

Code is now much shorter and it's easier to change the colour scheme.

#defs
$window_colour = Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }
$title_text_colour = Hsla{ hue:60 saturation:0.55 lightness:0.55 alpha:1.0 }

$tab_background_colour = Hsla{ hue:221 saturation:0.5 lightness:0.15 alpha:0.5 }

$tab_selected_background_colour = Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }

$tab_hover_colour = Hsla{ hue:24 saturation:0.5 lightness:0.50 alpha:1.0 }

$box_shadow_colour = Hsla{ hue:221 saturation:0.5 lightness:0.20 alpha:0.5 }

$text_line_idle_colour = Hsla{ hue:60 saturation:0.85 lightness:0.90 alpha:1.0 }
$text_line_hover_colour =  Hsla{ hue:221 saturation:0.0 lightness:1.0 alpha:1.0 }

$text_selected_colour = #FFFF00

$footer_text_colour = Hsla{hue:0 saturation:0.00 lightness:0.85 alpha:1.0}


+tab_selector = \
    RadioButton
    FlexNode{justify_main:Center}
    ControlRoot
    Multi<Animated<BackgroundColor>>[
        {
            idle: $tab_background_colour
            hover: $tab_hover_colour
        }
        {
            state: [Selected]
            idle: $tab_selected_background_colour
        }
    ]
    Multi<Animated<NodeShadow>>[
        {
            idle: {
                color:$box_shadow_colour
                x_offset: 0px,
                y_offset: 0px,
                spread_radius: 1px,
                blur_radius: 1px,
            }
        }
        {
            state: [Selected]
            idle: {
                color:$box_shadow_colour
                x_offset: 0px,
                y_offset: 0px,
                spread_radius: 10px,
                blur_radius: 10px,
            }
        }
    ]

    "text"
        TextLine // Placeholder
        ControlMember
        Multi<Animated<TextLineColor>>[
            {
                idle: $text_line_idle_colour
                hover: $text_line_hover_colour
            }
            {
                state: [Selected]
                idle: $text_selected_colour
            }
        ]  
\

#scenes
"main_scene"
    AbsoluteGridNode{left:30% width:40vw min_height:30vh   }
    BackgroundColor($window_colour)
    "title"
        FlexNode{justify_main:Center}
        "text"
            TextLine{ text: "Tabs education" }
            TextLineColor($title_text_colour)

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup
        "info"
            +tab_selector{
                "text"
                    TextLine{text:"Info"}
            }
        "exit"
            +tab_selector{
                "text"
                    TextLine{text:"Exit"}
            }

    // This is what changes based on menu selection
    "tab_content"
        GridNode{ height:25vh }
        BackgroundColor($tab_selected_background_colour)

    "footer_content"
            FlexNode{justify_main:Center}
            "text"
                TextLine{ text: "I don't change" }
                TextLineColor($footer_text_colour)
        


// Tab content that will be spawned at runtime

"info_tab"
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"You are in the info tab"}

"exit_tab"
    // Not implemented
    FlexNode{justify_main:Center}
    "text"
        TextLine{text:"Click me to quit"}

Adding one more tab

With our fancy new scene macro it would be nice to add a new tab before we clean up the rust code.

    // Actual tab menu
    "tab_menu"
        GridNode{grid_auto_flow:Column}
        RadioGroup //Stores RadioButton State
        "info"
            +tab_selector{
                "text"
                    TextLine{text:"Info"}
            }
        "calm"
            +tab_selector{
                "text"
                    TextLine{text:"Calm"}
            }
        "exit"
            +tab_selector{
                "text"
                    TextLine{text:"Exit"}
            }

Calm will just be empty

Rust code

This is our rust code from the previous chapter.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place in our scene
            let tab_content_entity = scene_handle.get("tab_content").id();
            scene_handle.edit("tab_menu::info", |scene_handle| {
                scene_handle.on_select(move |mut c: Commands, mut s: SceneBuilder| {
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "info_tab"), &mut s);
                    DONE
                });
            });
            // handling exit tab
            scene_handle.edit("tab_menu::exit", |scene_handle| {
                scene_handle.on_select(move |mut c: Commands, mut s: SceneBuilder| {
                    c.get_entity(tab_content_entity).result()?.despawn_descendants();

                    // Use this instead of c.get_entity()
                    c.ui_builder(tab_content_entity)
                        .spawn_scene_simple(("main.cob", "exit_tab"), &mut s);
                    DONE
                });
            });
        });
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

Starting tab

We can consolodate our tab change code into a function.

fn setup_tab_content(h: &mut UiSceneHandle, content_entity: Entity, scene: &'static str) {
    h.on_select(move |mut c: Commands, mut s: SceneBuilder| {
        c.get_entity(content_entity).result()?.despawn_descendants();
        c.ui_builder(content_entity)
            .spawn_scene_simple(("main.cob", scene), &mut s);
        DONE
    });
}

Now the code for using that function and setting up the starting tab.

use bevy::prelude::*;
use bevy_cobweb_ui::prelude::*;

//-------------------------------------------------------------------------------------------------------------------

fn setup_tab_content(h: &mut UiSceneHandle, content_entity: Entity, scene: &'static str) {
    h.on_select(move |mut c: Commands, mut s: SceneBuilder| {
        c.get_entity(content_entity).result()?.despawn_descendants();
        c.ui_builder(content_entity)
            .spawn_scene_simple(("main.cob", scene), &mut s);
        DONE
    });
}

//-------------------------------------------------------------------------------------------------------------------

fn build_ui(mut c: Commands, mut s: SceneBuilder) {
    c.spawn(Camera2d);
    c.ui_root()
        .spawn_scene(("main.cob", "main_scene"), &mut s, |scene_handle| {
            // Get entity to place our tab scenes in.
            let tab_content_entity = scene_handle.get("tab_content").id();

            scene_handle.edit("tab_menu::info", |scene_handle| {
                setup_tab_content(scene_handle, tab_content_entity, "info_tab");

                // Set this up as the starting tab.
                let id = scene_handle.id();
                scene_handle.react().entity_event(id, Select);
            });
            // calm tab
            scene_handle.edit("tab_menu::calm", |scene_handle| {
                setup_tab_content(scene_handle, tab_content_entity, "calm_tab");
            });
            // handling exit tab
            scene_handle.edit("tab_menu::exit", |scene_handle| {
                setup_tab_content(scene_handle, tab_content_entity, "exit_tab");
            });
        });
}

//-------------------------------------------------------------------------------------------------------------------

fn main() {
    App::new()
        .add_plugins(bevy::DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                window_theme: Some(bevy::window::WindowTheme::Dark),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(CobwebUiPlugin)
        .load("main.cob")
        .add_systems(OnEnter(LoadState::Done), build_ui)
        .run();
}

Errors

Odd lifetime errors, usually about static lifetimes

If you capture data in closures like .on_pressed(), make sure you use move and clone anything you need.

Trying to load non-top-level scenes

Only the top level scenes can be loaded as scenes independently.

Multiple manifests warning.

You may get a warning like below, it means you added a file to multiple manifests in different files.

WARN bevy_cobweb_ui::loading::cache::commands_buffer: reparenting file CobFile("ui/colour_scheme.cob") from Parent(CobFile("ui/panels/outliner.cob")) to Parent(CobFile("ui/moons.cob")) at /home/lyndonm/.cargo/git/checkouts/bevy_cobweb_ui-68d12fe85b5a400c/5b3a3aa/src/loading/cache/commands_buffer.rs:485

Failing to use ui_builder to load a scene

If you get an error like below when loading a scene inside another scene, then use the ui_builder extension with the entity you want to be the parent of the newly-spawned scene: commands.ui_builder(parent_entity).spawn_scene_simple(..)

WARN bevy_ui::layout: Node (233769v8) is in a non-UI entity hierarchy. You are using an entity with UI components as a child of an entity without UI components, your UI layout may be broken.
    at /home/lyndonm/.cargo/registry/src/index.crates.io-6f17d22bba15001f/bevy_ui-0.15.0-rc.3/src/layout/mod.rs:267