Compare commits

...

23 Commits

Author SHA1 Message Date
dc7122ed26 Merge branch 'main' into standard-renderer 2025-06-24 21:38:38 +02:00
78b46cb38e Update Voile and Voile.OpenAL projects .NET versions to 9.0. 2025-06-24 20:09:01 +02:00
6c3576891e Update TODO 2025-06-24 19:46:52 +02:00
03668849bc Fix any remaining bugs with anchor positioning system, use LocalPosition for UIElement, and make containers use that for arrangement. 2025-06-24 19:45:18 +02:00
b228f04670 Update TODO 2025-06-24 14:51:30 +02:00
a5d2668c18 Move layouting to Render instead of Update, use Update for input. 2025-06-24 14:48:55 +02:00
9a3512702a Update TODO. 2025-06-24 01:49:03 +02:00
61ac079f2b Unify Containers and Widgets by creating a base UIElement, add more anchor types, make anchor calculations an extension of Anchor. 2025-06-22 23:28:30 +02:00
95ae2de7ac Apply an anchor offset for Anchor.TopRight too. 2025-06-22 15:56:05 +02:00
683656dee8 Initial implementation of UI anchors, update TestGame. 2025-06-21 22:23:19 +02:00
30c438c407 Begin standard-renderer branch, fix wrong Voile to WebGPU color conversion. 2025-06-20 23:10:18 +02:00
ae1b612524 Workaround: cache inputs in RaylibInputSystem and force rendering at 60 FPS for more consistent inputs. 2025-06-20 23:02:46 +02:00
7e86898e1a WIP: UI input handling. 2025-06-20 22:24:30 +02:00
a1f56f49fb Update TODO 2025-06-20 20:21:57 +02:00
84efb2a3d1 Add ContainsPoint helper method to Widget. 2025-06-20 20:21:45 +02:00
5b29ea012e Add documentation to containers. 2025-06-20 20:16:36 +02:00
f0c721bb0f Add ConfineToChildren to Container, containers will use MinimumRect as a size if a given size is smaller than minimum, update documentation for Container. 2025-06-20 20:08:17 +02:00
bc95fff4a3 Merge FlexContainer, WIP dynamic container resizing. 2025-06-20 19:46:42 +02:00
76dafe9996 WIP: dirty flag for UI. 2025-06-20 19:34:10 +02:00
a9a8113dd9 Add RemoveChild to IParentableElement, only resize Container if children overflow it. 2025-06-20 19:14:29 +02:00
3154b3fa10 Add debug rectangle size rendering, auto-resize containers to fit all children. 2025-06-20 18:57:36 +02:00
1b09d80f7a Update TODO 2025-06-19 15:33:54 +02:00
63448210e2 Update TODO 2025-06-19 15:32:29 +02:00
28 changed files with 981 additions and 176 deletions

24
TODO.md
View File

@@ -1,5 +1,12 @@
# Voile MVP
## Bugfixes
- ActionJustPressed and KeyboardKeyJustPressed don't detect inputs consistently.
- **Solution**: This is a problem related to custom frame pacing for fixed timestep. Raylib polls input in BeginFrame, which means certain functions related to JustPressed* may work incorrectly when polled in a separate timestep. This can be fixed if the entire input system will be moved to SDL or with a custom render + input backend altogether.
- ~~Fix any remaining bugs with anchor positioning system.~~
- ~~Containers don't position their chilren correctly when using anchors and adding/removing them.~~
## Core
- ~~Add and implement interfaces for systems (ISystem, IUpdatableSystem, etc.)~~
@@ -23,6 +30,7 @@
- Serialize attribute.
- Add automatic serialization of resources through source generation and System.Text.Json.
- Make sure this serialization system works well with CLR and NativeAOT.
- ~~Provide means for fetching key/value configuration (INI? TOML?)~~
- Expose some sort of ConfigFile class for safe key/value configuration fetching.
@@ -63,9 +71,17 @@
## UI
- Basic widgets (button, label, text input)
- Layout
- ~~Layout~~
- ~~Containers~~
- ~~VerticalContainer~~
- ~~HorizontalContainer~~
- ~~GridContainer~~
- ~~FlexContainer~~
- ~~Positioning (anchors)~~
- ~~Move layouting to Render instead of Update, use Update for input.~~
- Input propagation
- Styling.
- Basic input elements (button, text field, toggle).
- Containers (vertical and horizontal).
- Styling
- Add style settings for UI panels (for buttons, labels, etc.).
- Find a way to reference external assets in the style (fonts, textures).
- Create a default style for widgets.

View File

@@ -19,7 +19,8 @@ public class TestGame : Game
{
InitializeSystemsDefault();
_uiSystem = new UISystem(new ResourceRef<Style>(Guid.Empty));
_uiSystem = new UISystem(Input, ResourceRef<Style>.Empty());
_uiSystem.RenderDebugRects = true;
_particleSystem = new ParticleSystem();
@@ -55,7 +56,9 @@ public class TestGame : Game
Input.AddInputMapping("reload", new IInputAction[] { new KeyInputAction(KeyboardKey.R) });
_emitterId = _particleSystem.CreateEmitter(Renderer.WindowSize / 2, _fireEffect);
_uiSystem.AddElement(_container);
_frame.AddChild(_container);
_uiSystem.AddElement(_frame);
}
@@ -72,22 +75,29 @@ public class TestGame : Game
_container.AddChild(new RectangleWidget(new Rect(32.0f, 32.0f), MathUtils.RandomColor()));
}
if (Input.IsActionPressed("cancel") && _container.Children.Count != 0)
{
var lastChild = _container.Children.Last();
_container.RemoveChild(lastChild);
}
if (Input.IsMouseButtonDown(MouseButton.Left))
{
_particleSystem.SetEmitterPosition(_emitterId, Input.GetMousePosition());
var mousePos = Input.GetMousePosition();
_frame.Size = new Rect(mousePos.X, mousePos.Y);
}
}
protected override void Render(double deltaTime)
{
Renderer.ClearBackground(Color.Black);
foreach (var emitter in _particleSystem!.Emitters)
{
DrawEmitter(emitter);
}
Renderer.ClearBackground(Color.CadetBlue);
// foreach (var emitter in _particleSystem!.Emitters)
// {
// DrawEmitter(emitter);
// }
Renderer.ResetTransform();
_uiSystem.Render(Renderer);
// Renderer.ResetTransform();
// _uiSystem.Render(Renderer);
}
private void DrawEmitter(ParticleEmitter emitter)
@@ -117,5 +127,22 @@ public class TestGame : Game
private ResourceRef<Sound> _sound;
private ResourceRef<Texture2d> _icon;
private GridContainer _container = new();
private FlexContainer _container = new(minimumSize: new Rect(64.0f, 64.0f), new())
{
Anchor = Anchor.Center,
Size = new Rect(500, 300),
Direction = FlexDirection.Column,
Justify = JustifyContent.Start,
Align = AlignItems.Center,
Wrap = true,
Gap = 10f
};
private Frame _frame = new();
// private VerticalContainer _container = new(new Rect(128.0f, 64.0f), new(), 16)
// {
// ConfineToContents = true,
// Anchor = Anchor.CenterLeft,
// AnchorOffset = new Vector2(0.5f, 0.0f)
// };
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<PublishAot>true</PublishAot>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

View File

@@ -108,7 +108,7 @@ namespace Voile
if (Renderer is null)
{
Renderer = new RaylibRenderSystem();
Renderer = new StandardRenderSystem();
}
if (Input is null)
@@ -164,6 +164,11 @@ namespace Voile
throw new NullReferenceException("No renderer provided.");
}
if (Input is null)
{
throw new NullReferenceException("No input system provided.");
}
Stopwatch stopwatch = Stopwatch.StartNew();
double previousTime = stopwatch.Elapsed.TotalSeconds;
@@ -175,6 +180,8 @@ namespace Voile
_accumulator += elapsedTime;
Input.Poll();
while (_accumulator >= UpdateTimeStep)
{
foreach (var system in _updatableSystems)

View File

@@ -33,4 +33,29 @@ namespace Voile.Input
private KeyboardKey _keyboardKey;
}
public struct MouseInputAction : IInputAction
{
public MouseButton MouseButton { get; private set; }
public MouseInputAction(MouseButton button)
{
MouseButton = button;
}
public bool IsPressed(InputSystem inputSystem)
{
return inputSystem.IsMousePressed(MouseButton);
}
public bool IsDown(InputSystem inputSystem)
{
return inputSystem.IsMouseButtonDown(MouseButton);
}
public bool IsReleased(InputSystem inputSystem)
{
return inputSystem.IsMouseButtonReleased(MouseButton);
}
}
}

View File

@@ -20,6 +20,12 @@ namespace Voile.Input
CreateDefaultMappings();
}
/// <summary>
/// Forces this input system to poll all of its inputs during current frame.<br />
/// Some backends require inputs to be polled once per specific interval. Override this method to implement this behavior.
/// </summary>
public virtual void Poll() { }
public void Shutdown() => Dispose();
public void Dispose() => GC.SuppressFinalize(this);
@@ -101,8 +107,12 @@ namespace Voile.Input
public abstract bool KeyboardKeyJustReleased(KeyboardKey key);
public abstract int GetCharPressed();
public abstract bool IsMousePressed(MouseButton button);
public abstract bool IsMouseButtonDown(MouseButton button);
public abstract bool IsMouseButtonReleased(MouseButton button);
public abstract float GetMouseWheelMovement();
public abstract void SetMousePosition(Vector2 position);
public abstract Vector2 GetMousePosition();
public abstract void HideCursor();
@@ -137,6 +147,9 @@ namespace Voile.Input
AddInputMapping("accept", [
new KeyInputAction(KeyboardKey.Enter),
]);
AddInputMapping("cancel", [
new KeyInputAction(KeyboardKey.Backspace),
]);
}
protected bool TryGetInputMappings(string forAction, [NotNullWhen(true)] out List<IInputAction>? inputActions)

View File

@@ -8,53 +8,63 @@ namespace Voile.Input
/// </summary>
public class RaylibInputSystem : InputSystem
{
public override int GetCharPressed()
public override void Poll()
{
return Raylib.GetCharPressed();
_justPressedKeys.Clear();
_justReleasedKeys.Clear();
_downKeys.Clear();
_pressedMouseButtons.Clear();
_releasedMouseButtons.Clear();
_downMouseButtons.Clear();
for (int key = 32; key <= 349; key++)
{
var k = (KeyboardKey)key;
if (Raylib.IsKeyPressed((Raylib_cs.KeyboardKey)k)) _justPressedKeys.Add(k);
if (Raylib.IsKeyReleased((Raylib_cs.KeyboardKey)k)) _justReleasedKeys.Add(k);
if (Raylib.IsKeyDown((Raylib_cs.KeyboardKey)k)) _downKeys.Add(k);
}
public override Vector2 GetMousePosition()
foreach (MouseButton button in System.Enum.GetValues(typeof(MouseButton)))
{
return Raylib.GetMousePosition();
var rayButton = (Raylib_cs.MouseButton)button;
if (Raylib.IsMouseButtonPressed(rayButton)) _pressedMouseButtons.Add(button);
if (Raylib.IsMouseButtonReleased(rayButton)) _releasedMouseButtons.Add(button);
if (Raylib.IsMouseButtonDown(rayButton)) _downMouseButtons.Add(button);
}
public override float GetMouseWheelMovement()
{
return Raylib.GetMouseWheelMove();
_mousePosition = Raylib.GetMousePosition();
_mouseWheelMove = Raylib.GetMouseWheelMove();
}
public override void HideCursor()
{
Raylib.HideCursor();
}
public override int GetCharPressed() => Raylib.GetCharPressed();
public override Vector2 GetMousePosition() => _mousePosition;
public override float GetMouseWheelMovement() => _mouseWheelMove;
public override bool IsKeyboardKeyDown(KeyboardKey key)
{
Raylib_cs.KeyboardKey rayKey = (Raylib_cs.KeyboardKey)key;
return Raylib.IsKeyDown(rayKey);
}
public override bool IsMouseButtonDown(MouseButton button)
{
return Raylib.IsMouseButtonDown((Raylib_cs.MouseButton)button);
}
public override bool KeyboardKeyJustPressed(KeyboardKey key)
{
Raylib_cs.KeyboardKey rayKey = (Raylib_cs.KeyboardKey)key;
return Raylib.IsKeyPressed(rayKey);
}
public override bool KeyboardKeyJustReleased(KeyboardKey key)
{
return Raylib.IsKeyReleased((Raylib_cs.KeyboardKey)key);
}
public override void SetMousePosition(Vector2 position)
{
Raylib.SetMousePosition((int)position.X, (int)position.Y);
}
public override void HideCursor() => Raylib.HideCursor();
public override void ShowCursor() => Raylib.ShowCursor();
public override bool IsCursorHidden() => Raylib.IsCursorHidden();
public override bool IsKeyboardKeyDown(KeyboardKey key) => _downKeys.Contains(key);
public override bool KeyboardKeyJustPressed(KeyboardKey key) => _justPressedKeys.Contains(key);
public override bool KeyboardKeyJustReleased(KeyboardKey key) => _justReleasedKeys.Contains(key);
public override bool IsMousePressed(MouseButton button) => _pressedMouseButtons.Contains(button);
public override bool IsMouseButtonReleased(MouseButton button) => _releasedMouseButtons.Contains(button);
public override bool IsMouseButtonDown(MouseButton button) => _downMouseButtons.Contains(button);
public override void SetMousePosition(Vector2 position) => Raylib.SetMousePosition((int)position.X, (int)position.Y);
private readonly HashSet<KeyboardKey> _justPressedKeys = new();
private readonly HashSet<KeyboardKey> _justReleasedKeys = new();
private readonly HashSet<KeyboardKey> _downKeys = new();
private readonly HashSet<MouseButton> _pressedMouseButtons = new();
private readonly HashSet<MouseButton> _releasedMouseButtons = new();
private readonly HashSet<MouseButton> _downMouseButtons = new();
private Vector2 _mousePosition;
private float _mouseWheelMove;
}
}

View File

@@ -49,6 +49,7 @@ namespace Voile.Rendering
_defaultFlags = flags;
Raylib.SetConfigFlags(flags);
Raylib.SetTargetFPS(settings.TargetFps);
}
public override void CreateAndInitializeWithWindow(RendererSettings settings)
@@ -187,6 +188,17 @@ namespace Voile.Rendering
}, transformPivot, transformRotation, VoileColorToRaylibColor(color));
}
public override void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1)
{
Raylib.DrawRectangleLinesEx(new Rectangle()
{
X = transformPosition.X,
Y = transformPosition.Y,
Width = size.X,
Height = size.Y
}, outlineWidth, VoileColorToRaylibColor(color));
}
public override void DrawDebugText(string text, int fontSize, Color color)
{
Raylib.DrawText(text, (int)transformPosition.X, (int)transformPosition.Y, fontSize, VoileColorToRaylibColor(color));

View File

@@ -180,6 +180,9 @@ namespace Voile.Rendering
/// <param name="size">Rectangle size.</param>
/// <param name="color">Fill color.</param>
public abstract void DrawRectangle(Vector2 size, Color color);
public abstract void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1.0f);
/// <summary>
/// Draws a debug text with a default font.
/// </summary>

View File

@@ -363,7 +363,7 @@ namespace Voile.Rendering
private Silk.NET.WebGPU.Color VoileColorToWebGPUColor(Color color)
{
return new Silk.NET.WebGPU.Color(color.R, color.G, color.B, color.A);
return new Silk.NET.WebGPU.Color((double)color.R / 255, (double)color.G / 255, (double)color.B / 255, (double)color.A / 255);
}
private unsafe RenderPassColorAttachment CreateClearColorAttachment(TextureView* view, Color clearColor)
@@ -399,6 +399,11 @@ namespace Voile.Rendering
throw new NotImplementedException();
}
public override void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1)
{
throw new NotImplementedException();
}
private Vector2 _windowSize = Vector2.Zero;
private IWindow? _window;

41
Voile/Source/UI/Anchor.cs Normal file
View File

@@ -0,0 +1,41 @@
using System.Numerics;
namespace Voile.UI;
public enum Anchor
{
TopLeft,
TopCenter,
TopRight,
CenterLeft,
Center,
CenterRight,
BottomLeft,
BottomCenter,
BottomRight,
Fill
}
public static class AnchorExtensions
{
public static Vector2 Calculate(this Anchor anchor, Vector2 parentPosition, Rect parentRect, Rect elementRect)
{
var size = new Vector2(elementRect.Width, elementRect.Height);
var parentSize = new Vector2(parentRect.Width, parentRect.Height);
return anchor switch
{
Anchor.TopLeft => Vector2.Zero,
Anchor.TopCenter => new Vector2((parentSize.X - size.X) / 2, 0),
Anchor.TopRight => new Vector2(parentSize.X - size.X, 0),
Anchor.CenterLeft => new Vector2(0, (parentSize.Y - size.Y) / 2),
Anchor.Center => (parentSize - size) / 2,
Anchor.CenterRight => new Vector2(parentSize.X - size.X, (parentSize.Y - size.Y) / 2),
Anchor.BottomLeft => new Vector2(0, parentSize.Y - size.Y),
Anchor.BottomCenter => new Vector2((parentSize.X - size.X) / 2, parentSize.Y - size.Y),
Anchor.BottomRight => parentSize - size,
_ => Vector2.Zero
};
}
}

View File

@@ -1,42 +1,136 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
// TODO: make Container extend Widget, it already implements similar behaviors.
/// <summary>
/// A base class for all UI containers, used to position and rendering child <see cref="IElement">s.
/// </summary>
public abstract class Container : IElement, IParentableElement, IUpdatableElement
public abstract class Container : UIElement, IParentableElement
{
public IReadOnlyList<IElement> Children => _children;
public Vector2 Position { get; set; }
public Rect Size { get; set; } = Rect.Zero;
/// <inheritdoc />
public IReadOnlyList<UIElement> Children => _children;
/// <summary>
/// Specifies if this <see cref="Container"/>'s minimum size will be confined to contents.
/// </summary>
public bool ConfineToContents { get; set; } = false;
public override Rect MinimumSize => _minimumSize;
public Container()
{
MarkDirty();
}
public Container(List<IElement> children)
public Container(Rect minimumSize)
{
_minimumSize = minimumSize;
MarkDirty();
}
public Container(Rect minimumSize, List<UIElement> children)
{
_minimumSize = minimumSize;
_children = children;
MarkDirty();
}
public void Update()
protected override void OnUpdate()
{
Arrange();
foreach (var child in _children)
{
if (child is not IUpdatableElement updatable) continue;
updatable.MarkDirty();
updatable.Update();
if (child is IAnchorableElement anchorable)
{
anchorable.ApplyAnchor(GlobalPosition, Size);
}
}
Arrange();
if (ConfineToContents)
{
RecalculateSizes();
}
}
/// <summary>
/// Called when this <see cref="Container"/> has to rearrange its children.
/// </summary>
public abstract void Arrange();
public void AddChild(IElement child)
/// <summary>
/// Recalculates sizes for this <see cref="Container"/>.
/// This is done automatically if <see cref="ConfineToContents"/> is true.
/// </summary>
public void RecalculateSizes()
{
float minX = float.MaxValue;
float minY = float.MaxValue;
float maxX = float.MinValue;
float maxY = float.MinValue;
foreach (var child in Children)
{
var pos = child.GlobalPosition;
var size = child.Size;
minX = MathF.Min(minX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxX = MathF.Max(maxX, pos.X + size.Width);
maxY = MathF.Max(maxY, pos.Y + size.Height);
}
float padding = 0f;
float occupiedWidth = (maxX - minX) + padding * 2;
float occupiedHeight = (maxY - minY) + padding * 2;
float finalWidth = MathF.Max(occupiedWidth, _minimumSize.Width);
float finalHeight = MathF.Max(occupiedHeight, _minimumSize.Height);
Size = new Rect(finalWidth, finalHeight);
if (_minimumSize > Size)
{
Size = _minimumSize;
}
}
public void AddChild(UIElement child)
{
_children.Add(child);
child.SetParent(this);
MarkDirty();
Update();
}
private List<IElement> _children = new();
public void RemoveChild(UIElement child)
{
_children.Remove(child);
MarkDirty();
Update();
}
public override void Render(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
renderable.Render(renderer, style);
}
}
private List<UIElement> _children = new();
private Rect _minimumSize = Rect.Zero;
}

View File

@@ -0,0 +1,185 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
public enum FlexDirection
{
Row,
Column
}
public enum JustifyContent
{
/// <summary>
/// Align children to the start of the line.
/// </summary>
Start,
/// <summary>
/// Align children to the center of the line.
/// </summary>
Center,
/// <summary>
/// Align children to the end of the line.
/// </summary>
End,
/// <summary>
/// Equal space between items (no space at edges if multiple).
/// </summary>
SpaceBetween
}
public enum AlignItems
{
/// <summary>
/// Align to the top or left (cross-axis)
/// </summary>
Start,
/// <summary>
/// Center along the cross-axis.
/// </summary>
Center,
/// <summary>
/// Align to the bottom or right.
/// </summary>
End
}
/// <summary>
/// A flexible layout container that arranges its child elements in rows or columns, similar to CSS flexbox. Supports wrapping, alignment, spacing, and space distribution.
/// </summary>
public class FlexContainer : Container
{
/// <summary>
/// Layout direction to use with this <see cref="FlexContainer"/>.
/// </summary>
public FlexDirection Direction { get; set; } = FlexDirection.Row;
/// <summary>
/// Controls main-axis alignment of child elements. By default, children will be positioned at the start of a line.
/// </summary>
public JustifyContent Justify { get; set; } = JustifyContent.Start;
/// <summary>
/// Controls cross-axis alignment (start, center, end).
/// </summary>
public AlignItems Align { get; set; } = AlignItems.Start;
/// <summary>
/// If true, children wrap onto multiple lines (rows or columns).
/// </summary>
public bool Wrap { get; set; } = false;
/// <summary>
/// Space between child elements.
/// </summary>
public float Gap { get; set; } = 16f;
/// <summary>
/// (Reserved) If true, distributes extra space among children (not implemented)
/// </summary>
public bool DistributeRemainingSpace { get; set; } = false;
public FlexContainer() : base() { }
public FlexContainer(Rect minimumSize, List<UIElement> children) : base(minimumSize, children) { }
public override void Arrange()
{
float containerMainSize = (Direction == FlexDirection.Row) ? Size.Width : Size.Height;
float mainPos = 0.0f;
float crossPos = 0.0f;
List<List<UIElement>> lines = new();
List<UIElement> currentLine = new();
float lineMainSum = 0f;
float maxCross = 0f;
foreach (var child in Children)
{
Vector2 childSize = GetChildSize(child);
float mainSize = GetMainSize(childSize);
float crossSize = GetCrossSize(childSize);
bool wrapLine = Wrap && currentLine.Count > 0 && (lineMainSum + mainSize + Gap > containerMainSize);
if (wrapLine)
{
lines.Add(currentLine);
currentLine = new();
lineMainSum = 0f;
maxCross += crossSize + Gap;
}
currentLine.Add(child);
lineMainSum += mainSize + (currentLine.Count > 1 ? Gap : 0);
}
if (currentLine.Count > 0)
{
lines.Add(currentLine);
}
float currentCross = crossPos;
foreach (var line in lines)
{
float lineMainLength = line.Sum(child => GetMainSize(GetChildSize(child))) + Gap * (line.Count - 1);
float justifyOffset = GetJustifyOffset(containerMainSize, lineMainLength, line.Count);
float currentMain = mainPos + justifyOffset;
float maxLineCross = line.Select(child => GetCrossSize(GetChildSize(child))).Max();
foreach (var child in line)
{
Vector2 childSize = GetChildSize(child);
float alignedCross = currentCross + GetAlignOffset(maxLineCross, GetCrossSize(childSize));
Vector2 childPos = (Direction == FlexDirection.Row)
? new Vector2(currentMain, alignedCross)
: new Vector2(alignedCross, currentMain);
child.LocalPosition = childPos;
currentMain += GetMainSize(childSize) + Gap;
}
currentCross += maxLineCross + Gap;
}
}
private Vector2 GetChildSize(IElement child)
{
if (child is IResizeableElement resizeable)
return new Vector2(resizeable.MinimumSize.Width, resizeable.MinimumSize.Height);
return new Vector2(child.Size.Width, child.Size.Height);
}
private float GetMainSize(Vector2 size)
{
return Direction == FlexDirection.Row ? size.X : size.Y;
}
private float GetCrossSize(Vector2 size)
{
return Direction == FlexDirection.Row ? size.Y : size.X;
}
private float GetJustifyOffset(float containerSize, float totalMain, int itemCount)
{
return Justify switch
{
JustifyContent.Center => (containerSize - totalMain) / 2f,
JustifyContent.End => containerSize - totalMain,
JustifyContent.SpaceBetween => (itemCount > 1) ? 0 : (containerSize - totalMain) / 2f,
_ => 0
};
}
private float GetAlignOffset(float maxCross, float childCross)
{
return Align switch
{
AlignItems.Center => (maxCross - childCross) / 2f,
AlignItems.End => maxCross - childCross,
_ => 0f
};
}
}

View File

@@ -0,0 +1,19 @@
namespace Voile.UI.Containers;
public class Frame : Container
{
public Frame()
{
}
public Frame(Rect minimumSize) : base(minimumSize)
{
}
public override void Arrange()
{
}
}

View File

@@ -3,15 +3,26 @@ using Voile.Rendering;
namespace Voile.UI.Containers;
public class GridContainer : Container, IRenderableElement
/// <summary>
/// A grid container arranges its children in a grid layout with a given column count, and column and row spacing between child elements.
/// </summary>
public class GridContainer : Container
{
/// <summary>
/// Amount of columns this grid has.
/// </summary>
public int Columns { get; set; } = 2;
/// <summary>
/// Spacing between each grid column.
/// </summary>
public float ColumnSpacing { get; set; } = 16.0f;
/// <summary>
/// Spacing between each grid row.
/// </summary>
public float RowSpacing { get; set; } = 16.0f;
public bool Visible { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public GridContainer(List<IElement> children, int columns = 2, float colSpacing = 16.0f, float rowSpacing = 16.0f)
: base(children)
public GridContainer(Rect minimumSize, List<UIElement> children, int columns = 2, float colSpacing = 16.0f, float rowSpacing = 16.0f)
: base(minimumSize, children)
{
Columns = columns;
ColumnSpacing = colSpacing;
@@ -27,16 +38,13 @@ public class GridContainer : Container, IRenderableElement
public override void Arrange()
{
float startX = Position.X;
float startY = Position.Y;
float currentX = startX;
float currentY = startY;
float currentX = 0.0f;
float currentY = 0.0f;
int colIndex = 0;
foreach (var child in Children)
{
child.Position = new Vector2(currentX, currentY);
child.LocalPosition = new Vector2(currentX, currentY);
float childWidth = 0.0f;
float childHeight = 0.0f;
@@ -49,7 +57,7 @@ public class GridContainer : Container, IRenderableElement
if (colIndex >= Columns)
{
colIndex = 0;
currentX = startX;
currentX = 0.0f;
currentY += childHeight + RowSpacing;
}
else
@@ -58,13 +66,4 @@ public class GridContainer : Container, IRenderableElement
}
}
}
public void Render(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
renderable.Render(renderer, style);
}
}
}

View File

@@ -3,12 +3,17 @@ using Voile.Rendering;
namespace Voile.UI.Containers;
public class HorizontalContainer : Container, IRenderableElement
/// <summary>
/// A horizontal container arranges its children horizontally, with a given horizontal spacing between child elements.
/// </summary>
public class HorizontalContainer : Container
{
/// <summary>
/// Horizontal spacing between child elements.
/// </summary>
public float Spacing { get; set; } = 16.0f;
public bool Visible { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public HorizontalContainer(List<IElement> children, float spacing = 16.0f) : base(children)
public HorizontalContainer(Rect minimumSize, List<UIElement> children, float spacing = 16.0f) : base(minimumSize, children)
{
Spacing = spacing;
}
@@ -20,13 +25,13 @@ public class HorizontalContainer : Container, IRenderableElement
public override void Arrange()
{
float currentX = Position.X;
float currentX = 0.0f;
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
var pos = new Vector2(currentX, Position.Y);
child.Position = pos;
var pos = new Vector2(currentX, 0.0f);
child.LocalPosition = pos;
currentX += child.Size.Width;
@@ -36,13 +41,4 @@ public class HorizontalContainer : Container, IRenderableElement
}
}
}
public void Render(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
renderable.Render(renderer, style);
}
}
}

View File

@@ -3,12 +3,17 @@ using Voile.Rendering;
namespace Voile.UI.Containers;
public class VerticalContainer : Container, IRenderableElement
/// <summary>
/// A vertical container arranges its children vertically, with a given vertical spacing between child elements.
/// </summary>
public class VerticalContainer : Container
{
/// <summary>
/// Vertical spacing between child elements.
/// </summary>
public float Spacing { get; set; } = 16.0f;
public bool Visible { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public VerticalContainer(List<IElement> children, float spacing = 16.0f) : base(children)
public VerticalContainer(Rect minimumSize, List<UIElement> children, float spacing = 16.0f) : base(minimumSize, children)
{
Spacing = spacing;
}
@@ -20,13 +25,13 @@ public class VerticalContainer : Container, IRenderableElement
public override void Arrange()
{
float currentY = Position.Y;
float currentY = 0.0f;
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
var pos = new Vector2(Position.X, currentY);
child.Position = pos;
var pos = new Vector2(0.0f, currentY);
child.LocalPosition = pos;
currentY += child.Size.Height;
@@ -36,13 +41,4 @@ public class VerticalContainer : Container, IRenderableElement
}
}
}
public void Render(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
renderable.Render(renderer, style);
}
}
}

View File

@@ -6,14 +6,32 @@ namespace Voile.UI;
public interface IElement
{
public Vector2 Position { get; set; }
/// <summary>
/// This element's position in pixels relative to the viewport top-left edge.
/// </summary>
public Vector2 GlobalPosition { get; }
/// <summary>
/// The size of this element.
/// </summary>
public Rect Size { get; set; }
}
public interface IParentableElement
{
public IReadOnlyList<IElement> Children { get; }
public void AddChild(IElement child);
/// <summary>
/// This parentable element's children.
/// </summary>
public IReadOnlyList<UIElement> Children { get; }
/// <summary>
/// Add a child element to this element.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/>.</param>
public void AddChild(UIElement child);
/// <summary>
/// Remove a child element from this element.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/> to remove.</param>
public void RemoveChild(UIElement child);
}
public interface IResizeableElement
@@ -21,22 +39,60 @@ public interface IResizeableElement
/// <summary>
/// Get a minimum rectangle size for this element.
/// </summary>
public abstract Rect MinimumRect { get; }
public abstract Rect MinimumSize { get; }
}
public interface IUpdatableElement
{
/// <summary>
/// Specifies if this element's properties have changed, making it necessary to update it.
/// </summary>
public bool Dirty { get; }
/// <summary>
/// Update this element.
/// </summary>
void Update();
/// <summary>
/// Marks this element as changed, requiring an update.
/// </summary>
void MarkDirty();
}
public interface IRenderableElement
{
/// <summary>
/// Specifies if this element should be drawn.
/// </summary>
public bool Visible { get; set; }
/// <summary>
/// Render this element.
/// </summary>
/// <param name="renderer">Renderer to call draw operations on.</param>
/// <param name="style">A style to use to draw this element.</param>
public void Render(RenderSystem renderer, Style style);
/// <summary>
/// Draws this element's size bounds.
/// </summary>
/// <param name="renderer">Renderer to use.</param>
public void DrawSize(RenderSystem renderer);
}
public interface IInputElement
{
/// <summary>
/// Specifies if this element should ignore inputs.
/// </summary>
public bool IgnoreInput { get; set; }
void Input(IInputAction action);
/// <summary>
/// Send an input action to this element.
/// </summary>
/// <param name="action">Input action to send.</param>
void Input(UIInputContext action);
}
public interface IAnchorableElement
{
public Anchor Anchor { get; set; }
public Vector2 AnchorOffset { get; set; }
public void ApplyAnchor(Vector2 parentPosition, Rect parentRect);
}

View File

@@ -5,5 +5,19 @@ namespace Voile.UI;
/// </summary>
public record Rect(float Width = 0.0f, float Height = 0.0f)
{
public float Width { get; set; } = Width;
public float Height { get; set; } = Height;
public static Rect Zero => new Rect(0.0f, 0.0f);
public float Area => Width * Height;
public int CompareTo(Rect? other)
{
if (other is null) return 1;
return Area.CompareTo(other.Area);
}
public static bool operator >(Rect left, Rect right) => left.CompareTo(right) > 0;
public static bool operator <(Rect left, Rect right) => left.CompareTo(right) < 0;
public static bool operator >=(Rect left, Rect right) => left.CompareTo(right) >= 0;
public static bool operator <=(Rect left, Rect right) => left.CompareTo(right) <= 0;
}

View File

@@ -0,0 +1,95 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI;
public abstract class UIElement : IElement, IRenderableElement, IResizeableElement, IUpdatableElement, IAnchorableElement
{
public bool Visible { get; set; } = true;
public bool IgnoreInput { get; set; } = false;
public Vector2 LocalPosition { get; set; } = Vector2.Zero;
public Vector2 GlobalPosition => _parent?.GlobalPosition + LocalPosition ?? LocalPosition;
public Rect Size
{
get => _size;
set
{
_size = value;
if (value.Width < MinimumSize.Width)
{
_size.Width = MinimumSize.Width;
}
if (value.Height < MinimumSize.Height)
{
_size.Height = MinimumSize.Height;
}
MarkDirty();
}
}
public Vector2 AnchorOffset { get; set; } = Vector2.Zero;
public Anchor Anchor { get; set; } = Anchor.TopLeft;
public abstract Rect MinimumSize { get; }
public bool Dirty => _dirty;
public virtual void MarkDirty() => _dirty = true;
public void SetParent(UIElement parent)
{
_parent = parent;
}
public void Update()
{
if (!_dirty) return;
_dirty = false;
if (Size == Rect.Zero)
Size = MinimumSize;
OnUpdate();
if (_parent is not null && _parent.Size != Rect.Zero)
{
ApplyAnchor(_parent.GlobalPosition, _parent.Size);
}
}
public abstract void Render(RenderSystem renderer, Style style);
protected abstract void OnUpdate();
public void DrawSize(RenderSystem renderer)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangleOutline(new Vector2(Size.Width, Size.Height), Color.Red, 2.0f);
}
/// <summary>
/// Determines if this <see cref="UIElement"/> contains a point within its confines.
/// </summary>
/// <param name="pointPosition">A global position of the point.</param>
/// <returns>True if the point is inside the widget; otherwise, false.</returns>
public bool ContainsPoint(Vector2 point)
{
return point.X >= GlobalPosition.X && point.Y >= GlobalPosition.Y &&
point.X <= GlobalPosition.X + Size.Width &&
point.Y <= GlobalPosition.Y + Size.Height;
}
/// <summary>
/// Applies this <see cref="UIElement"/> anchor.
/// </summary>
public virtual void ApplyAnchor(Vector2 parentPosition, Rect parentRect)
{
LocalPosition = Anchor.Calculate(parentPosition, parentRect, Size) + new Vector2(AnchorOffset.X, AnchorOffset.Y);
}
private bool _dirty = true;
private Rect _size = Rect.Zero;
private UIElement? _parent;
}

View File

@@ -0,0 +1,35 @@
using System.Numerics;
using Voile.Input;
namespace Voile.UI;
public class UIInputContext
{
public IInputAction Action { get; }
public Vector2 MousePosition { get; }
public bool MousePressed { get; set; }
public bool MouseReleased { get; set; }
public bool MouseDown { get; set; }
public string ActionName { get; }
public int CharPressed { get; }
public bool HasCharInput => CharPressed != 0;
public bool Handled => _handled;
/// <summary>
/// Marks this context as handled, meaning next elements that will receive it should discard it.
/// </summary>
public void SetHandled() => _handled = true;
public UIInputContext(IInputAction action, Vector2 mousePosition, string actionName, int charPressed = 0)
{
Action = action;
MousePosition = mousePosition;
ActionName = actionName;
CharPressed = charPressed;
}
private bool _handled;
}

View File

@@ -1,5 +1,6 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
using Voile.UI.Containers;
namespace Voile.UI;
@@ -7,40 +8,140 @@ public class UISystem : IUpdatableSystem, IRenderableSystem
{
public IReadOnlyList<IElement> Elements => _elements;
public UISystem(ResourceRef<Style> style)
public bool RenderDebugRects { get; set; }
public UISystem(InputSystem inputSystem)
{
_style = ResourceRef<Style>.Empty();
_input = inputSystem;
}
public UISystem(InputSystem inputSystem, ResourceRef<Style> style)
{
_input = inputSystem;
_style = style;
}
public UISystem(ResourceRef<Style> style, List<IElement> elements)
public UISystem(InputSystem inputSystem, ResourceRef<Style> style, List<UIElement> elements)
{
_input = inputSystem;
_style = style;
_elements = elements;
}
public void AddElement(IElement element) => _elements.Add(element);
public void RemoveElement(IElement element) => _elements.Remove(element);
public void AddElement(UIElement element) => _elements.Add(element);
public void RemoveElement(UIElement element) => _elements.Remove(element);
public void Update(double deltaTime)
{
HandleInput();
}
public void Render(RenderSystem renderer)
{
// Update elements each time UI system is rendered.
foreach (var element in _elements)
{
if (element is not IUpdatableElement updatable) continue;
updatable.Update();
}
}
public void Render(RenderSystem renderer)
{
foreach (var element in _elements)
{
if (element is IRenderableElement renderable)
{
renderable.Render(renderer, _style.Value);
if (!RenderDebugRects) return;
renderable.DrawSize(renderer);
}
if (element is IParentableElement parentable)
{
foreach (var child in parentable.Children)
{
if (child is not IRenderableElement renderableChild) continue;
if (!RenderDebugRects) return;
renderableChild.DrawSize(renderer);
}
}
}
}
private void HandleInput()
{
int charPressed = _input.GetCharPressed();
Vector2 mousePos = _input.GetMousePosition();
Vector2 currentMousePosition = _input.GetMousePosition();
foreach (var (actionName, mappings) in InputSystem.InputMappings)
{
foreach (var action in mappings)
{
if (action.IsPressed(_input))
{
// TODO: specify which mouse button is used in the context.
var context = new UIInputContext(action, mousePos, actionName, charPressed)
{
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonReleased(MouseButton.Left),
};
if (PropagateInput(_elements, context))
return;
}
}
}
if (charPressed != 0)
{
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed);
PropagateInput(_elements, context);
}
if (currentMousePosition != _lastMousePosition)
{
// TODO: specify which mouse button is used in the context.
var context = new UIInputContext(new MouseInputAction(MouseButton.Left), mousePos, "", charPressed)
{
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonReleased(MouseButton.Left),
};
PropagateInput(_elements, context);
}
}
private bool PropagateInput(List<UIElement> elements, UIInputContext context)
{
for (int i = elements.Count - 1; i >= 0; i--)
{
var element = elements[i];
if (element is IInputElement inputElement && !inputElement.IgnoreInput)
{
inputElement.Input(context);
_input.SetAsHandled();
// return true;
}
if (element is IParentableElement parent)
{
if (PropagateInput(parent.Children.ToList(), context))
return true;
}
}
return false;
}
private ResourceRef<Style> _style;
private List<IElement> _elements = new();
private List<UIElement> _elements = new();
private InputSystem _input;
private Vector2 _lastMousePosition = Vector2.Zero;
}

View File

@@ -18,7 +18,7 @@ public enum ButtonState
public class Button : Widget
{
public string Label { get; set; } = "Button";
public override Rect MinimumRect => new Rect(Width: 128.0f, Height: 64.0f);
public override Rect MinimumSize => new Rect(Width: 128.0f, Height: 64.0f);
public Button(string label, Action pressedAction)
{
@@ -29,14 +29,19 @@ public class Button : Widget
public override void Render(RenderSystem renderer, Style style)
{
// TODO: use a button color from style.
renderer.SetTransform(Position, Vector2.Zero);
renderer.DrawRectangle(new Vector2(MinimumRect.Width, MinimumRect.Height), new Color(0.25f, 0.25f, 0.25f));
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(new Vector2(MinimumSize.Width, MinimumSize.Height), new Color(0.25f, 0.25f, 0.25f));
}
public override void Input(IInputAction action)
protected override void OnInput(UIInputContext action)
{
}
protected override void OnUpdate()
{
throw new NotImplementedException();
}
private Action _pressedAction;
}

View File

@@ -6,7 +6,7 @@ namespace Voile.UI.Widgets;
public class Label : Widget
{
public override Rect MinimumRect => throw new NotImplementedException();
public override Rect MinimumSize => throw new NotImplementedException();
public string Text { get; set; } = "Hello World!";
@@ -21,7 +21,7 @@ public class Label : Widget
_fontOverride = fontOverride;
}
public override void Input(IInputAction action)
protected override void OnInput(UIInputContext action)
{
throw new NotImplementedException();
}
@@ -30,9 +30,14 @@ public class Label : Widget
{
// TODO: use style here.
if (!_fontOverride.HasValue) return;
renderer.SetTransform(Position, Vector2.Zero);
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawText(_fontOverride, Text, Color.White);
}
protected override void OnUpdate()
{
throw new NotImplementedException();
}
private ResourceRef<Font> _fontOverride = ResourceRef<Font>.Empty();
}

View File

@@ -6,22 +6,73 @@ namespace Voile.UI.Widgets;
public class RectangleWidget : Widget
{
public override Rect MinimumRect { get; }
public override Rect MinimumSize { get; }
public Color Color { get; set; } = Color.White;
public RectangleWidget(Rect minimumRect, Color color)
public RectangleWidget(Rect minimumRect, Color color) : base()
{
MinimumRect = minimumRect;
MinimumSize = minimumRect;
Color = color;
}
public override void Input(IInputAction action)
{
throw new NotImplementedException();
_defaultColor = color;
_defaultSize = Size;
_hoverColor = color.Lightened(0.25f);
}
public override void Render(RenderSystem renderer, Style style)
{
renderer.SetTransform(Position, Vector2.Zero);
renderer.DrawRectangle(new Vector2(MinimumRect.Width, MinimumRect.Height), Color);
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(new Vector2(Size.Width, Size.Height), Color);
}
protected override void OnInput(UIInputContext inputContext)
{
if (_defaultSize == Rect.Zero)
{
_defaultSize = Size;
}
if (_downSize == Rect.Zero)
{
_downSize = new Rect(Size.Width * 0.75f, Size.Height * 0.75f);
}
var mouseInside = ContainsPoint(inputContext.MousePosition);
if (mouseInside)
{
Color = _hoverColor;
}
else
{
Color = _defaultColor;
}
// if (mouseInside && inputContext.MouseDown)
// {
// Size = _downSize;
// }
// if (mouseInside && inputContext.MouseReleased)
// {
// Size = _defaultSize;
// }
if (mouseInside && inputContext.MousePressed)
{
Console.WriteLine("Hello, I was clicked!");
}
}
protected override void OnUpdate()
{
}
private Color _defaultColor;
private Color _hoverColor;
private Rect _defaultSize = Rect.Zero;
private Rect _downSize = Rect.Zero;
}

View File

@@ -7,42 +7,37 @@ namespace Voile.UI.Widgets;
/// <summary>
/// A base class for all UI widgets.
/// </summary>
public abstract class Widget : IElement, IRenderableElement, IInputElement, IResizeableElement, IUpdatableElement
public abstract class Widget : UIElement, IInputElement
{
public bool Visible { get; set; } = true;
public bool IgnoreInput { get; set; }
public Vector2 Position { get; set; } = Vector2.Zero;
public Rect Size { get; set; } = new();
public Widget()
{
MarkDirty();
}
public Widget(Rect size)
{
Size = size;
MarkDirty();
}
public Widget(Vector2 position)
{
Position = position;
LocalPosition = position;
MarkDirty();
}
/// </inheritdoc>
public abstract Rect MinimumRect { get; }
public void Input(UIInputContext context)
{
if (context.Handled || IgnoreInput) return;
if (ContainsPoint(context.MousePosition))
{
OnInput(context);
}
}
/// <summary>
/// Called when its time to draw this widget.
/// </summary>
/// <param name="renderer"></param>
public abstract void Render(RenderSystem renderer, Style style);
/// <summary>
/// Called when this widget receives input.
/// </summary>
/// <param name="action">An input action this widget received.</param>
public abstract void Input(IInputAction action);
public void Update()
{
if (Size == Rect.Zero)
{
Size = MinimumRect;
}
}
protected abstract void OnInput(UIInputContext context);
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
@@ -15,7 +15,7 @@
<PackageReference Include="SoLoud.NET" Version="2020.2.7.1" />
<PackageReference Include="Tommy" Version="3.1.2" />
<PackageReference Include="ImGui.NET" Version="1.89.4" />
<PackageReference Include="Raylib-cs" Version="6.1.1" />
<PackageReference Include="Raylib-cs" Version="7.0.1" />
<PackageReference Include="SharpFont" Version="4.0.1" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19" />
<PackageReference Include="StbImageSharp" Version="2.27.13" />