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.
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
or1.2E3
. - Integer-to-float conversion:
1
can be written instead of1.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
andControlMembers
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