Skip to main content

UI system

· 5 min read
Admer456
Lead developer; resident fox

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.
  • Widgets:
    • These are methods that fill in a Panel with some concrete event handling logic and rendering logic.
  • 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:

MainMenu.cs
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:

ColorPickerWidget.cs
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:

ColumnUtilities.cs
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:

MapEditor.cs
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:

DebugMenu.cs
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:

style-light.css
.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;
}
style-dark.css
.primary
{
background-color: rgb( 32, 32, 32, 1.0 );
color: rgb( 200, 200, 200, 1.0 );
}

/*.secondary, .tertiary etc.*/
custom.css
/* 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" )
);