UI system
Finally, it was about time I talked about this as well. Elegy's UI system.
In short, while ImGui is fine for debug menus and such, it's not really adequate for extensive in-game and tooling menus. Solutions like RmlUI are good for in-game GUI, however it's in C++. I would prefer a C#-only solution.
I figured, it wouldn't be a bad idea to tackle this problem myself. Essentially, build a lightweight UI library without too many fancy features. Something that will be good enough for basic game GUI needs as well as basic app/tool needs.
Besides, I've wanted to explore some alternative approaches to UI, and hopefully make it fairly data-driven too. It would be driven by C# scripts, as described in the scripting system blog.
Requirements, scope, architecture
So let's discuss the requirements and scope a bit. With this UI library, I'd like to have the following:
- Vector font text rendering.
- UI panels (rectangles).
- Containers: vbox, hbox, grid, scrollbox.
- Docking support for tools.
- Styling:
- Defined in external files.
- Animation.
- Plenty of widgets out of the box:
- Colour pickers.
- Buttons.
- Images.
- Labels.
- Text input.
- Popup/context menu widget.
- Events: on mouse hover/click/hold/release, on key pressed/released etc.
Fundamentally, it'd be architected like so, with the following concepts:
- Panels:
- These are the building blocks of the UI system. The system manipulates with lists and trees of
Panel
objects. They themselves are very barebones, equipped with a few events, a few callbacks, some basic builtin info, and a databag.
- These are the building blocks of the UI system. The system manipulates with lists and trees of
- Widgets:
- These are methods that fill in a
Panel
with some concrete event handling logic and rendering logic.
- These are methods that fill in a
- Windows:
- These are objects that fundamentally spawn and manage all widgets. They themselves are panels too!
- Styles:
- Finally, these are a bit like CSS. They just define colours, sizes, fonts and animations/transitions.
Vision
Here's a form example:
using Elegy.UI;
// Imports MenuButton
using static Example.UI.MenuContainers;
// Imports Hbox, Vbox, Filler etc.
using static Elegy.UI.Containers;
public class MainMenu : Window
{
// This is called once
public override Panel Emit() // Window.Emit
=> FullscreenPanel( // Covers the whole screen
Hbox( // Horizontal box
Vbox( // Vertical box to store main menu buttons
Filler(), // Yes, what you see below are localised strings
MenuButton( "#MENU_StartGame", OnStartGame, Align.Left ),
MenuButton( "#MENU_LoadGame", OnLoadGame, Align.Left ),
MenuButton( "#MENU_Settings", OnSettings, Align.Left ),
Filler( size: 20 ),
MenuButton( "#MENU_Quit", OnQuitGame, Align.Left ),
Filler()
),
Filler() // Fill the right half of the screen
// Alternatively, you may use a grid here
)
);
private void OnStartGame()
=> ...;
private void OnLoadGame()
=> ...;
private void OnSettings()
=> ...;
private void OnQuitGame()
=> ...;
}
Here's a widget example:
using Elegy.UI;
public class ColorPickerData : IWidgetData
{
public Vector4 Color { get; set; } = Vector4.One;
public List<Vector4> ColorHistory { get; set; } = [];
}
public static class ColorPickerWidget
{
public static void OnRender( UiRenderContext context )
{
//...
}
public static Panel ColorPicker()
{
Panel panel = new();
panel.OnRender = OnRender;
panel.DataBag = new ColorPickerData();
return self;
}
}
That is an example of a more advanced widget. You may also simply define widgets as an extension method that calls others, combines other widgets to act as a bigger one:
using Elegy.UI;
using static Elegy.UI.Containers;
public static class ColumnUtilities
{
public static Panel DoubleColumn( Panel leftChild, Panel rightChild )
=> Hbox( leftChild, VLine(), rightChild );
public static Panel TripleColumn( Panel leftChild, Panel middleChild, Panel rightChild )
=> Hbox( leftChild, VLine(), middleChild, VLine(), rightChild );
}
Here's a docking example:
using Elegy.UI;
using static Elegy.UI.Containers;
using static MapEditor.UI.Browsers;
using static MapEditor.UI.Editors;
using static MapEditor.UI.Inspectors;
public class MapEditorUi : Window
{
public override Panel Emit()
=> Dock(
// This is an optional default layout
DockVbox(
Toolbar(),
DockHbox(
MapEditorFrame(),
DockHbox(
EntityInspector(),
WorldInspector(),
ScriptInspector()
)
),
DockHbox(
MapEditorMessages(),
AssetBrowser()
),
StatusBar()
)
);
}
Finally here is also a more dynamic, conditional UI example:
using Elegy.UI;
public class DebugMenu : Window
{
public bool Chance()
=> Random.Shared.GetNext() > 5000;
public override Panel Emit()
=> Vbox(
// Example 1: simple conditional
Conditional(
predicate: Chance,
ifTrue: () => Label( "Now you see me :)" )
),
// Example 2: if-else conditional
Conditional(
predicate: Chance,
ifTrue: () => Label( "Luck is on your side!" ),
ifFalse: () => Label( "Luck is NOT on your side :(" )
),
// Example 3: conditional chain
// Though you may also chain Conditional
// multiple times on ifFalse
ConditionalChain(
{
Predicate = Chance,
Action = () => Label( "Luck is 1" )
},
{
Predicate = Chance,
Action = () => Label( "Luck is 2" )
},
{
Predicate = Chance,
Action = () => Label( "Luck is 3" )
}
)
);
}
Finally, here's an example of a couple styles:
.primary
{
background-color: rgb( 220, 220, 220, 1.0 );
color: rgb( 15, 15, 15, 1.0 );
}
/*.secondary, .tertiary etc.*/
.button
{
background-color: rgb( 15, 15, 15, 0.7 );
color: rgb( 220, 220, 220, 1.0 );
transition: color 0.25s, background-color 0.25s;
}
.button:hover
{
background-color: rgb( 20, 20, 20, 0.7 );
color: rgb( 230, 230, 230, 1.0 );
transition: color 0.25s, background-color 0.25s;
}
.primary
{
background-color: rgb( 32, 32, 32, 1.0 );
color: rgb( 200, 200, 200, 1.0 );
}
/*.secondary, .tertiary etc.*/
/* A fancy button class */
.button-fancy
{
background-color: rgb( 64, 48, 16, 0.7 );
}
// Usage example
renderContext.StyleManager.Load( "ui/styles/custom.css" );
return Vbox(
Button( "Normal button" ),
Button( "Fancy button :)" ).Style( "secondary button-fancy" )
);