Compare commits

...

5 Commits

15 changed files with 664 additions and 129 deletions

View File

@@ -1,12 +1,9 @@
[Button] [Button]
BackgroundColor = "#0f62fe" BackgroundColor = "#0f62fe"
CornerRadius = 16.0
TextColor = "#ffffff" TextColor = "#ffffff"
Padding = 16.0 Padding = 16.0
# Creates an empty rule for Button.Normal.
# This will inherit style from Button, this is a temporary workaround.
[Button.Normal]
[Button.Hovered] [Button.Hovered]
BackgroundColor = "#0353e9" BackgroundColor = "#0353e9"
@@ -61,3 +58,17 @@ BorderColor = "#161616"
# Default background color for all Container derived classes. # Default background color for all Container derived classes.
[Container] [Container]
BackgroundColor = "#e0e0e0" BackgroundColor = "#e0e0e0"
[InputField]
TextColor = "#161616"
BorderColor = "#8d8d8d"
BorderSize = [0, 0, 0, 1]
Padding = [16, 16, 4, 8]
[InputField.Focused]
BorderColor = "#0f62fe"
BorderSize = 2.0
# [InputField.Hovered]BorderColor = "#0f62fe"
# BackgroundColor = "#f4f4f4"

View File

@@ -20,7 +20,7 @@ public class TestGame : Game
InitializeSystemsDefault(); InitializeSystemsDefault();
_uiSystem = new UISystem(Input); _uiSystem = new UISystem(Input);
// _uiSystem.RenderDebugRects = true; _uiSystem.RenderDebugRects = true;
ResourceManager.EnableFileWatching(); ResourceManager.EnableFileWatching();
@@ -73,7 +73,7 @@ public class TestGame : Game
_uiSystem.SetStyleSheet(_styleSheet); _uiSystem.SetStyleSheet(_styleSheet);
var addButton = new Button("Default button", _defaultFontSet); var addButton = new Button("", _defaultFontSet);
var removeButton = new Button("Danger button", _defaultFontSet); var removeButton = new Button("Danger button", _defaultFontSet);
@@ -93,10 +93,16 @@ public class TestGame : Game
Anchor = Anchor.TopCenter Anchor = Anchor.TopCenter
}; };
var inputField = new InputField(string.Empty, _defaultFontSet)
{
PlaceholderText = "Hello, World!"
};
c.AddChild(addButton); c.AddChild(addButton);
c.AddChild(removeButton); c.AddChild(removeButton);
c.AddChild(outlineButton); c.AddChild(outlineButton);
c.AddChild(linkButton); c.AddChild(linkButton);
c.AddChild(inputField);
var vc = new VerticalContainer(0.0f); var vc = new VerticalContainer(0.0f);
vc.AddChild(c); vc.AddChild(c);

View File

@@ -25,11 +25,8 @@ namespace Voile.Input
/// Some backends require inputs to be polled once per specific interval. Override this method to implement this behavior. /// Some backends require inputs to be polled once per specific interval. Override this method to implement this behavior.
/// </summary> /// </summary>
public virtual void Poll() { } public virtual void Poll() { }
public void Shutdown() => Dispose(); public void Shutdown() => Dispose();
public void Dispose() => GC.SuppressFinalize(this); public void Dispose() => GC.SuppressFinalize(this);
public bool Handled { get => _handled; set => _handled = value; } public bool Handled { get => _handled; set => _handled = value; }
public bool IsActionDown(string action) public bool IsActionDown(string action)
@@ -137,18 +134,20 @@ namespace Voile.Input
new KeyInputAction(KeyboardKey.Down), new KeyInputAction(KeyboardKey.Down),
]); ]);
AddInputMapping("left", [ AddInputMapping("left", [
new KeyInputAction(KeyboardKey.A),
new KeyInputAction(KeyboardKey.Left), new KeyInputAction(KeyboardKey.Left),
]); ]);
AddInputMapping("right", [ AddInputMapping("right", [
new KeyInputAction(KeyboardKey.D),
new KeyInputAction(KeyboardKey.Right), new KeyInputAction(KeyboardKey.Right),
]); ]);
AddInputMapping("accept", [ AddInputMapping("accept", [
new KeyInputAction(KeyboardKey.Enter), new KeyInputAction(KeyboardKey.Enter),
]); ]);
AddInputMapping("cancel", [ AddInputMapping("cancel", [
new KeyInputAction(KeyboardKey.Backspace), new KeyInputAction(KeyboardKey.Escape),
]);
AddInputMapping("backspace", [
new KeyInputAction(KeyboardKey.Backspace)
]); ]);
} }

View File

@@ -3,23 +3,61 @@ namespace Voile;
/// <summary> /// <summary>
/// Represents a rectangle. Used to determine widget confines for UI layout. /// Represents a rectangle. Used to determine widget confines for UI layout.
/// </summary> /// </summary>
public record Rect(float Width = 0.0f, float Height = 0.0f) public class Rect(float width = 0.0f, float height = 0.0f)
{ {
public float Width { get; set; } = Width; public float Width { get; set; } = width;
public float Height { get; set; } = Height; public float Height { get; set; } = height;
public static Rect Zero => new Rect(0.0f, 0.0f); public static Rect Zero => new Rect(0.0f, 0.0f);
public float Area => Width * Height; public float Area => Width * Height;
public int CompareTo(Rect? other) public int CompareTo(Rect other) => Area.CompareTo(other.Area);
{
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;
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;
/// <summary>
/// Returns the rectangle with the smallest width.
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public static Rect MinWidth(Rect a, Rect b)
{
if (a < b)
{
return a;
}
else if (a > b)
{
return b;
}
return a;
}
public static Rect MaxWidth(Rect a, Rect b)
{
if (a > b)
{
return a;
}
if (a < b)
{
return b;
}
return a;
}
public static Rect operator +(Rect left, Rect right)
{
var width = left.Width + right.Width;
var height = left.Height + right.Height;
return new(width, height);
}
} }
/// <summary> /// <summary>
@@ -32,6 +70,8 @@ public struct Size : IEquatable<Size>
public float Top; public float Top;
public float Bottom; public float Bottom;
public static Size Zero => new Size(0.0f);
public Size(float uniform) public Size(float uniform)
{ {
Left = Right = Top = Bottom = uniform; Left = Right = Top = Bottom = uniform;
@@ -51,8 +91,6 @@ public struct Size : IEquatable<Size>
Bottom = bottom; Bottom = bottom;
} }
public static Size Zero => new Size(0);
public static Rect operator +(Size margin, Rect rect) => public static Rect operator +(Size margin, Rect rect) =>
new Rect(rect.Width + margin.Left + margin.Right, new Rect(rect.Width + margin.Left + margin.Right,
rect.Height + margin.Top + margin.Bottom); rect.Height + margin.Top + margin.Bottom);

View File

@@ -252,7 +252,27 @@ namespace Voile.Rendering
var rayFont = _fontPool[font.Handle]; var rayFont = _fontPool[font.Handle];
Raylib.DrawTextPro(rayFont, text, transformPosition, transformPivot, transformRotation, font.Size, 0.0f, VoileColorToRaylibColor(color)); float x = transformPosition.X;
float y = transformPosition.Y;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (i > 0)
x += font.GetKerning(text[i - 1], c);
Raylib.DrawTextCodepoint(
rayFont,
c,
new Vector2(x, y),
font.Size,
VoileColorToRaylibColor(color)
);
var glyph = font.GetGlyph(c);
x += glyph.Advance * font.SpacingScale + font.LetterSpacing;
}
} }
protected override int GetMonitorWidth(int monitorId) protected override int GetMonitorWidth(int monitorId)

View File

@@ -9,9 +9,14 @@ namespace Voile;
public struct Glyph public struct Glyph
{ {
public int TextureId { get; set; } = -1; public int TextureId { get; set; } = -1;
public Vector2 Offset;
public float Width { get; set; } public float Width { get; set; }
public float Height { get; set; } public float Height { get; set; }
public Vector2 Bearing { get; set; } public Vector2 Bearing { get; set; }
/// <summary>
/// Glyph's advance in pixels.
/// </summary>
public int Advance { get; set; } public int Advance { get; set; }
public Glyph() { } public Glyph() { }
@@ -26,7 +31,10 @@ public class Font : Resource, IUpdatableResource, IDisposable
/// Internal handle for the font. If it got successfully loaded into the GPU, the value will be other than -1. /// Internal handle for the font. If it got successfully loaded into the GPU, the value will be other than -1.
/// </summary> /// </summary>
internal int Handle { get; set; } = -1; internal int Handle { get; set; } = -1;
public int Size { get; set; } = 16; public int Size { get; set; } = 24;
public float LetterSpacing { get; set; } = 0f;
public float SpacingScale { get; set; } = 0.8f; // TODO: this is a super temporary fix for character spacing. Should be fixed once custom font rendering will be implemented.
public byte[]? Buffer { get; private set; } public byte[]? Buffer { get; private set; }
public long BufferSize { get; set; } public long BufferSize { get; set; }
@@ -75,19 +83,29 @@ public class Font : Resource, IUpdatableResource, IDisposable
/// <returns>A <see cref="Rect"/> with the sizes of a given text using this font.</returns> /// <returns>A <see cref="Rect"/> with the sizes of a given text using this font.</returns>
public Rect Measure(string text) public Rect Measure(string text)
{ {
if (string.IsNullOrEmpty(text)) return Measure(text.AsSpan());
}
/// <summary>
/// Measures a given <see cref="ReadOnlySpan"/> of characters.
/// </summary>
/// <param name="chars"></param>
/// <returns>A <see cref="Rect"/> with the sizes of a given text using this font.</returns>
public Rect Measure(ReadOnlySpan<char> chars)
{
if (chars.Length == 0)
return Rect.Zero; return Rect.Zero;
float totalWidth = 0; float totalWidth = 0;
float maxAscent = 0; float maxAscent = 0;
float maxDescent = 0; float maxDescent = 0;
for (int i = 0; i < text.Length; i++) for (int i = 0; i < chars.Length; i++)
{ {
char c = text[i]; char c = chars[i];
Glyph glyph = GetGlyph(c); Glyph glyph = GetGlyph(c);
totalWidth += glyph.Advance; totalWidth += glyph.Advance * SpacingScale + LetterSpacing;
float ascent = glyph.Bearing.Y; float ascent = glyph.Bearing.Y;
float descent = glyph.Height - glyph.Bearing.Y; float descent = glyph.Height - glyph.Bearing.Y;
@@ -99,7 +117,7 @@ public class Font : Resource, IUpdatableResource, IDisposable
if (i > 0) if (i > 0)
{ {
char prevChar = text[i - 1]; char prevChar = chars[i - 1];
totalWidth += GetKerning(prevChar, c); totalWidth += GetKerning(prevChar, c);
} }
} }
@@ -112,10 +130,10 @@ public class Font : Resource, IUpdatableResource, IDisposable
{ {
unsafe unsafe
{ {
if (FacePtr == IntPtr.Zero) var face = (FT_FaceRec_*)FacePtr;
return 0;
FT_FaceRec_* face = (FT_FaceRec_*)FacePtr; if (face == null)
return 0;
uint leftIndex = FT_Get_Char_Index(face, left); uint leftIndex = FT_Get_Char_Index(face, left);
uint rightIndex = FT_Get_Char_Index(face, right); uint rightIndex = FT_Get_Char_Index(face, right);
@@ -127,7 +145,7 @@ public class Font : Resource, IUpdatableResource, IDisposable
if (FT_Get_Kerning(face, leftIndex, rightIndex, FT_Kerning_Mode_.FT_KERNING_DEFAULT, &kerning) != 0) if (FT_Get_Kerning(face, leftIndex, rightIndex, FT_Kerning_Mode_.FT_KERNING_DEFAULT, &kerning) != 0)
return 0; return 0;
return (int)kerning.x; return (int)(kerning.x >> 6);
} }
} }
@@ -154,31 +172,48 @@ public class Font : Resource, IUpdatableResource, IDisposable
unsafe unsafe
{ {
var face = (FT_FaceRec_*)FacePtr; var face = (FT_FaceRec_*)FacePtr;
if (FacePtr == IntPtr.Zero)
return false;
return FT_Get_Char_Index(face, character) != 0; return FT_Get_Char_Index(face, character) != 0;
} }
} }
internal unsafe void InitializeFontSize()
{
var face = (FT_FaceRec_*)FacePtr;
if (face == null)
throw new Exception("Font face not initialized.");
FT_Set_Pixel_Sizes(face, 0, (uint)Size);
}
private unsafe Glyph LoadGlyph(char character) private unsafe Glyph LoadGlyph(char character)
{ {
var face = (FT_FaceRec_*)FacePtr; var face = (FT_FaceRec_*)FacePtr;
// TODO: for now, we're loading glyphs for metrics, but when implementing WebGPU rendering, we want to somehow render them to SDF or bitmap. var error = FT_Load_Char(face, character, FT_LOAD_RENDER);
FT_Error error = FT_Set_Pixel_Sizes(face, 0, (uint)Size);
error = FT_Load_Char(face, character, FT_LOAD_NO_BITMAP);
if (error != 0) if (error != 0)
throw new Exception($"Failed to load glyph for '{character}'"); throw new Exception($"Failed to load glyph for '{character}'");
var glyph = face->glyph; var g = face->glyph;
var bitmap = glyph->bitmap; var bitmap = g->bitmap;
var metrics = glyph->metrics; var metrics = g->metrics;
return new Glyph return new Glyph
{ {
Width = metrics.width >> 6, Width = bitmap.width,
Height = metrics.height >> 6, Height = bitmap.rows,
Bearing = new Vector2(metrics.horiBearingX >> 6, metrics.horiBearingY >> 6),
Advance = (int)metrics.horiAdvance >> 6, Bearing = new Vector2(
metrics.horiBearingX >> 6,
metrics.horiBearingY >> 6
),
Advance = (int)(g->advance.x >> 6)
}; };
} }

View File

@@ -21,11 +21,14 @@ public class FontLoader : ResourceLoader<Font>
byte[] fileBuffer = new byte[stream.Length]; byte[] fileBuffer = new byte[stream.Length];
int bytesRead = stream.Read(fileBuffer, 0, fileBuffer.Length); int bytesRead = stream.Read(fileBuffer, 0, fileBuffer.Length);
var result = new Font(path, fileBuffer);
var result = new Font(path, fileBuffer);
result.BufferSize = bytesRead; result.BufferSize = bytesRead;
LoadFaceData(result); LoadFaceData(result);
result.InitializeFontSize();
result.LoadAsciiData(); result.LoadAsciiData();
return result; return result;

View File

@@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using Voile.Input;
using Voile.Rendering; using Voile.Rendering;
namespace Voile.UI; namespace Voile.UI;
@@ -18,6 +19,19 @@ public interface IElement
public Rect Size { get; set; } public Rect Size { get; set; }
} }
/// <summary>
/// Represents a UI element that requires a frame-independent update tick loop.
/// </summary>
public interface ITickableElement
{
/// <summary>
/// Excutes unconditionally on every frame engine loop step.
/// </summary>
/// <param name="dt">Elapsed delta frame time in seconds.</param>
/// <param name="input">InputSystem that this tickable element should use to poll input events.</param>
void Tick(float dt, InputSystem input);
}
/// <summary> /// <summary>
/// Represents a UI element that can contain child elements. /// Represents a UI element that can contain child elements.
/// </summary> /// </summary>

View File

@@ -11,7 +11,7 @@ namespace Voile.UI;
/// </summary> /// </summary>
public class Style public class Style
{ {
public enum AnimationType public enum TransitionType
{ {
Linear, Linear,
EaseIn, EaseIn,
@@ -20,7 +20,7 @@ public class Style
} }
public float TransitionDuration = 0f; public float TransitionDuration = 0f;
public AnimationType TransitionType = AnimationType.Linear; public TransitionType Transition = TransitionType.Linear;
public Style() { } public Style() { }
@@ -35,17 +35,39 @@ public class Style
/// Merges this <see cref="Style"/> with a different one.<br /> /// Merges this <see cref="Style"/> with a different one.<br />
/// Properties that are not set for this <see cref="Style"/> will be inherited from <paramref name="overrideStyle"/>. /// Properties that are not set for this <see cref="Style"/> will be inherited from <paramref name="overrideStyle"/>.
/// </summary> /// </summary>
/// <param name="overrideStyle"></param> /// <param name="other"></param>
/// <returns>A merged <see cref="Style"/>.</returns> /// <returns>A merged <see cref="Style"/>.</returns>
public Style Merge(Style overrideStyle) public Style Merge(Style other)
{ {
return new Style return new Style
{ {
BackgroundColor = overrideStyle.BackgroundColor != default ? overrideStyle.BackgroundColor : BackgroundColor, Padding =
TextColor = overrideStyle.TextColor != default ? overrideStyle.TextColor : TextColor, other.Padding ?? Padding,
Padding = overrideStyle.Padding != default ? overrideStyle.Padding : Padding,
BorderSize = overrideStyle.BorderSize != default ? overrideStyle.BorderSize : BorderSize, BackgroundColor =
BorderColor = overrideStyle.BorderColor != default ? overrideStyle.BorderColor : BorderColor, other.BackgroundColor ?? BackgroundColor,
BorderSize =
other.BorderSize ?? BorderSize,
BorderColor =
other.BorderColor ?? BorderColor,
TextColor =
other.TextColor ?? TextColor,
CornerRadius =
other.CornerRadius != default
? other.CornerRadius
: CornerRadius,
TransitionDuration =
other.TransitionDuration != default
? other.TransitionDuration
: TransitionDuration,
Transition =
other.Transition
}; };
} }
} }
@@ -88,12 +110,12 @@ public class StyleSheetLoader : ResourceLoader<StyleSheet>
{ {
var style = new Style(); var style = new Style();
string easingName = reader.GetString("TransitionType", "Linear"); string transitionName = reader.GetString("TransitionType", "Linear");
if (!Enum.TryParse<Style.AnimationType>(easingName, true, out var easing)) if (!Enum.TryParse<Style.TransitionType>(transitionName, true, out var transition))
easing = Style.AnimationType.Linear; throw new ArgumentException($"\"{transitionName}\" is not a valid TransitionType.");
style.TransitionType = easing; style.Transition = transition;
if (reader.HasKey("BackgroundColor")) if (reader.HasKey("BackgroundColor"))
@@ -147,9 +169,13 @@ public class StyleSheet : Resource
public void Add(string key, Style style) => _styles.Add(key, style); public void Add(string key, Style style) => _styles.Add(key, style);
public bool TryGet(string styleName, [NotNullWhen(true)] out Style? style) public bool TryGet(
string styleName,
[NotNullWhen(true)] out Style? style)
{ {
return _styles.TryGetValue(styleName, out style); style = Resolve(styleName);
return style != null;
} }
public static StyleSheet Default => new(new Dictionary<string, Style>() public static StyleSheet Default => new(new Dictionary<string, Style>()
@@ -226,5 +252,29 @@ public class StyleSheet : Resource
}}, }},
}); });
private Style? Resolve(string fullKey)
{
var parts = fullKey.Split('.');
Style? merged = null;
for (int i = 1; i <= parts.Length; i++)
{
var subKey = string.Join('.',
parts.Take(i));
if (_styles.TryGetValue(
subKey,
out var style))
{
merged ??= new Style();
merged = merged.Merge(style);
}
}
return merged;
}
private Dictionary<string, Style> _styles = new(); private Dictionary<string, Style> _styles = new();
} }

View File

@@ -13,14 +13,14 @@ public class StyleAnimator
_elapsed = 0f; _elapsed = 0f;
} }
public static float Ease(float t, Style.AnimationType type) public static float Ease(float t, Style.TransitionType type)
{ {
return type switch return type switch
{ {
Style.AnimationType.Linear => t, Style.TransitionType.Linear => t,
Style.AnimationType.EaseIn => t * t, Style.TransitionType.EaseIn => t * t,
Style.AnimationType.EaseOut => t * (2 - t), Style.TransitionType.EaseOut => t * (2 - t),
Style.AnimationType.EaseInOut => t < 0.5f Style.TransitionType.EaseInOut => t < 0.5f
? 2 * t * t ? 2 * t * t
: -1 + (4 - 2 * t) * t, : -1 + (4 - 2 * t) * t,
_ => t _ => t
@@ -31,7 +31,7 @@ public class StyleAnimator
{ {
_elapsed = MathF.Min(_elapsed + deltaTime, _duration); _elapsed = MathF.Min(_elapsed + deltaTime, _duration);
float t = _duration == 0 ? 1 : _elapsed / _duration; float t = _duration == 0 ? 1 : _elapsed / _duration;
float easedT = Ease(t, _to.TransitionType); float easedT = Ease(t, _to.Transition);
return LerpStyle(_from, _to, easedT); return LerpStyle(_from, _to, easedT);
} }
@@ -45,7 +45,7 @@ public class StyleAnimator
Padding = MathUtils.LerpSize(from.Padding ?? Size.Zero, to.Padding ?? Size.Zero, t), Padding = MathUtils.LerpSize(from.Padding ?? Size.Zero, to.Padding ?? Size.Zero, t),
BorderColor = MathUtils.LerpColor(from.BorderColor ?? Color.Transparent, to.BorderColor ?? Color.Transparent, t), BorderColor = MathUtils.LerpColor(from.BorderColor ?? Color.Transparent, to.BorderColor ?? Color.Transparent, t),
BorderSize = MathUtils.LerpSize(from.BorderSize ?? Size.Zero, to.BorderSize ?? Size.Zero, t), BorderSize = MathUtils.LerpSize(from.BorderSize ?? Size.Zero, to.BorderSize ?? Size.Zero, t),
TransitionType = to.TransitionType Transition = to.Transition
}; };
return result; return result;

View File

@@ -42,24 +42,23 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
get => _size; get => _size;
set set
{ {
if (value.Width < MinimumSize.Width) float width = Math.Max(value.Width, MinimumSize.Width);
{ float height = Math.Max(value.Height, MinimumSize.Height);
_size.Width = MinimumSize.Width;
}
if (value.Height < MinimumSize.Height) bool changed =
{ _size.Width != width ||
_size.Height = MinimumSize.Height; _size.Height != height;
}
if (!changed)
return;
_size.Width = width;
_size.Height = height;
if (_size != value)
{
MarkDirty(); MarkDirty();
} }
}
_size = value;
}
}
public Vector2 AnchorOffset { get; set; } = Vector2.Zero; public Vector2 AnchorOffset { get; set; } = Vector2.Zero;
public Anchor Anchor { get; set; } = Anchor.TopLeft; public Anchor Anchor { get; set; } = Anchor.TopLeft;
@@ -211,6 +210,7 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var modifier in modifiers) foreach (var modifier in modifiers)
{ {
if (string.IsNullOrEmpty(modifier)) continue;
sb.Append($".{modifier}"); sb.Append($".{modifier}");
} }

View File

@@ -17,15 +17,15 @@ public class UIInputContext
/// </summary> /// </summary>
public Vector2 MousePosition { get; } public Vector2 MousePosition { get; }
/// <summary> /// <summary>
/// Determines if a mouse button was pressed. /// Determines if a left mouse button was pressed.
/// </summary> /// </summary>
public bool MousePressed { get; set; } public bool MousePressed { get; set; }
/// <summary> /// <summary>
/// Determines if a mouse button was released. /// Determines if a left mouse button was released.
/// </summary> /// </summary>
public bool MouseReleased { get; set; } public bool MouseReleased { get; set; }
/// <summary> /// <summary>
/// Determines if a mouse button is currently held. /// Determines if a left mouse button is currently held.
/// </summary> /// </summary>
public bool MouseDown { get; set; } public bool MouseDown { get; set; }
/// <summary> /// <summary>
@@ -37,6 +37,31 @@ public class UIInputContext
/// </summary> /// </summary>
public int CharPressed { get; } public int CharPressed { get; }
/// <summary>
/// Determines if the current input action corresponds to a cancel operation (ESC on the keyboard, B on the Xbox controller, etc.)
/// </summary>
public bool IsCancel => ActionName == "cancel";
/// <summary>
/// Determines if the current input action corresponds to a backspace operation. Used for text inputs.
/// </summary>
public bool IsBackspace => ActionName == "backspace";
/// <summary>
/// Determines if the current input action corresponds to an accept/submit operation (ENTER on the keyboard, A on the Xbox controller, etc.)
/// </summary>
public bool IsAccept => ActionName == "accept";
public bool IsLeft => ActionName == "left";
public bool IsRight => ActionName == "right";
public bool IsUp => ActionName == "up";
public bool IsDown => ActionName == "down";
public float DeltaTime { get; init; }
/// <summary>
/// Determines if control key is pressed.
/// </summary>
public bool Control { get; set; }
/// <summary> /// <summary>
/// Determines if this <see cref="UIInputContext"/> registered any character input from keyboard. /// Determines if this <see cref="UIInputContext"/> registered any character input from keyboard.
/// </summary> /// </summary>

View File

@@ -39,21 +39,19 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
public void Update(double deltaTime) public void Update(double deltaTime)
{ {
// HandleInput(); float dt = (float)deltaTime;
} PropagateTick(_elements, dt);
HandleInput((float)deltaTime);
public void Render(RenderSystem renderer)
{
// Update elements each time UI system is rendered.
HandleInput();
foreach (var element in _elements) foreach (var element in _elements)
{ {
if (element is not IUpdatableElement updatable) continue; if (element is not IUpdatableElement updatable) continue;
updatable.Update(); updatable.Update();
} }
}
public void Render(RenderSystem renderer)
{
foreach (var element in _elements) foreach (var element in _elements)
{ {
if (element is IRenderableElement renderable) if (element is IRenderableElement renderable)
@@ -104,50 +102,90 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
} }
} }
private void HandleInput() private void HandleInput(float deltaTime)
{ {
int charPressed = _input.GetCharPressed(); int charPressed = _input.GetCharPressed();
Vector2 mousePos = _input.GetMousePosition(); Vector2 mousePos = _input.GetMousePosition();
Vector2 currentMousePosition = _input.GetMousePosition(); Vector2 currentMousePosition = _input.GetMousePosition();
bool inputHandled = false;
// Process Actions (Pressed or Held)
foreach (var (actionName, mappings) in InputSystem.InputMappings) foreach (var (actionName, mappings) in InputSystem.InputMappings)
{ {
foreach (var action in mappings) foreach (var action in mappings)
{ {
if (action.IsPressed(_input)) bool isPressed = action.IsPressed(_input);
bool isDown = action.IsDown(_input);
if (isPressed)
{ {
// TODO: specify which mouse button is used in the context.
var context = new UIInputContext(action, mousePos, actionName, charPressed) var context = new UIInputContext(action, mousePos, actionName, charPressed)
{ {
MouseDown = _input.IsMouseButtonDown(MouseButton.Left), MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left), MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonReleased(MouseButton.Left), MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
DeltaTime = deltaTime
}; };
if (PropagateInput(_elements, context)) if (PropagateInput(_elements, context))
{
inputHandled = true;
break;
}
}
}
if (inputHandled) break;
}
if (inputHandled)
{
_lastMousePosition = currentMousePosition;
return; return;
} }
}
}
if (charPressed != 0) if (charPressed != 0)
{ {
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed); var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed)
PropagateInput(_elements, context); {
DeltaTime = deltaTime
};
if (PropagateInput(_elements, context))
{
_lastMousePosition = currentMousePosition;
return;
}
} }
var frameContext = new UIInputContext(new MouseInputAction(MouseButton.Left), mousePos, "", charPressed)
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), MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left), MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonPressed(MouseButton.Left), MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
DeltaTime = deltaTime
}; };
PropagateInput(_elements, context);
PropagateInput(_elements, frameContext);
_lastMousePosition = currentMousePosition;
}
private void PropagateTick(List<UIElement> elements, float dt)
{
for (int i = 0; i < elements.Count; i++)
{
var element = elements[i];
if (!element.Visible) continue;
if (element is ITickableElement tickable)
{
tickable.Tick(dt, _input);
}
if (element is IParentableElement parentable)
{
PropagateTick(parentable.Children.ToList(), dt);
}
} }
} }
@@ -157,20 +195,19 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{ {
var element = elements[i]; var element = elements[i];
// if (!element.ContainsPoint(context.MousePosition)) continue;
if (element is IInputElement inputElement && !inputElement.IgnoreInput)
{
inputElement.Input(context);
_input.SetAsHandled();
// return true;
}
if (element is IParentableElement parent) if (element is IParentableElement parent)
{ {
if (PropagateInput(parent.Children.ToList(), context)) if (PropagateInput(parent.Children.ToList(), context))
return true; return true;
} }
if (element is IInputElement input && !input.IgnoreInput)
{
input.Input(context);
if (context.Handled)
return true;
}
} }
return false; return false;

View File

@@ -1,23 +1,23 @@
using System.Numerics; using System.Numerics;
using Voile.Rendering; using Voile.Rendering;
using Voile.Resources; using Voile.Resources;
using Voile.UI.Containers;
namespace Voile.UI.Widgets; namespace Voile.UI.Widgets;
public enum ButtonState
{
Disabled,
Hovered,
Pressed,
Normal
}
/// <summary> /// <summary>
/// A clickable button with a label. /// A clickable button with a label.
/// </summary> /// </summary>
public class Button : Widget public class Button : Widget
{ {
public enum ButtonState
{
Disabled,
Hovered,
Pressed,
Focused,
Normal
}
public string Text public string Text
{ {
get => _text; set get => _text; set
@@ -93,8 +93,12 @@ public class Button : Widget
var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top); var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero); renderer.SetTransform(textPosition, Vector2.Zero);
if (_suitableFont.HasValue)
{
renderer.DrawText(_suitableFont, _text, textColor); renderer.DrawText(_suitableFont, _text, textColor);
} }
}
protected virtual void Pressed() { } protected virtual void Pressed() { }
@@ -138,6 +142,7 @@ public class Button : Widget
protected override void OnUpdate() protected override void OnUpdate()
{ {
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty(); ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
foreach (var c in _text) foreach (var c in _text)
{ {
if (FontSet.TryGetFontFor(c, out var fallbackFont)) if (FontSet.TryGetFontFor(c, out var fallbackFont))
@@ -149,10 +154,13 @@ public class Button : Widget
} }
} }
if (fontRef.HasValue)
{
_suitableFont = fontRef; _suitableFont = fontRef;
var font = _suitableFont.Value; var font = _suitableFont.Value;
_textSize = font.Measure(_text); _textSize = font.Measure(_text);
}
Size = _padding + _textSize; Size = _padding + _textSize;
} }

View File

@@ -0,0 +1,289 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
using Voile.Resources;
namespace Voile.UI.Widgets;
public class InputField : Widget, ITickableElement
{
enum ActionType { None, Backspace, Left, Right }
public override Rect MinimumSize => _placeholderSize + _padding;
public override string? StyleElementName => nameof(InputField);
public override string[]? StyleModifiers =>
[
_isFocused ? "Focused" : string.Empty
];
/// <summary>
/// Current text value of this input field.
/// </summary>
public string Value
{
get => _input;
set
{
_input = value ?? string.Empty;
_cursor = Math.Clamp(_cursor, 0, _input.Length);
MarkDirty();
}
}
public string PlaceholderText { get; set; } = string.Empty;
public bool IsFocused => _isFocused;
public FontSet FontSet { get; set; } = new();
public InputField(string initialText, FontSet fontSet)
{
_input = initialText ?? string.Empty;
FontSet = fontSet;
MarkDirty();
Update();
}
public void Tick(float dt, InputSystem input)
{
if (!_isFocused)
return;
HandleRepeatingActions(dt, input);
}
protected override void OnInput(UIInputContext context)
{
HandleFocus(context);
if (!_isFocused)
{
_lastActiveAction = ActionType.None;
_repeatTimer = 0.0f;
return;
}
HandleTextInput(context);
ClampCursor();
}
private void HandleFocus(UIInputContext context)
{
if (!context.MousePressed)
return;
bool isInside = ContainsPoint(context.MousePosition);
_isFocused = isInside;
if (isInside)
context.SetHandled();
}
private void HandleTextInput(UIInputContext context)
{
if (!context.HasCharInput)
return;
char c = (char)context.CharPressed;
if (!char.IsControl(c))
{
_input = _input.Insert(_cursor, c.ToString());
_cursor++;
MarkDirty();
}
context.SetHandled();
}
private void HandleRepeatingActions(float dt, InputSystem input)
{
ActionType currentAction = ActionType.None;
if (input.IsKeyboardKeyDown(KeyboardKey.Backspace)) currentAction = ActionType.Backspace;
else if (input.IsKeyboardKeyDown(KeyboardKey.Left)) currentAction = ActionType.Left;
else if (input.IsKeyboardKeyDown(KeyboardKey.Right)) currentAction = ActionType.Right;
if (currentAction == ActionType.None)
{
_lastActiveAction = ActionType.None;
_repeatTimer = 0.0f;
return;
}
bool shouldExecute = false;
if (_lastActiveAction != currentAction)
{
shouldExecute = true;
_repeatTimer = 0.0f;
_lastActiveAction = currentAction;
}
else
{
_repeatTimer += dt;
if (_repeatTimer >= INITIAL_DELAY)
{
shouldExecute = true;
_repeatTimer = INITIAL_DELAY - REPEAT_RATE;
}
}
if (shouldExecute)
{
ExecuteAction(currentAction);
}
}
private void ExecuteAction(ActionType action)
{
switch (action)
{
case ActionType.Backspace:
if (_cursor > 0 && _input.Length > 0)
{
_input = _input.Remove(_cursor - 1, 1);
_cursor--;
MarkDirty();
}
break;
case ActionType.Left:
if (_cursor > 0)
{
_cursor--;
MarkDirty();
}
break;
case ActionType.Right:
if (_cursor < _input.Length)
{
_cursor++;
MarkDirty();
}
break;
}
}
private void ClampCursor()
{
_cursor = Math.Clamp(_cursor, 0, _input.Length);
}
protected override void OnRender(RenderSystem renderer, Style style)
{
if (_padding != style.Padding)
{
MarkDirty();
}
_padding = style.Padding ?? Voile.Size.Zero;
var textColor = style.TextColor ?? Color.Black;
var caretColor = style.BorderColor ?? Color.Black;
var textPosition = new Vector2(GlobalPosition.X + _padding.Left, GlobalPosition.Y + _padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
// Placeholder
// TODO: use a color from the style instead of making it less transparent.
if (string.IsNullOrEmpty(_input) && !string.IsNullOrEmpty(PlaceholderText))
{
var placeholderColor = textColor.Lightened(0.5f);
if (_suitableFont.HasValue)
{
renderer.DrawText(_suitableFont, PlaceholderText, placeholderColor);
}
}
else
{
if (_suitableFont.HasValue)
{
renderer.DrawText(_suitableFont, _input, textColor);
}
}
// Caret
if (_isFocused && _blink)
{
var caretX = MeasureTextWidth(_input.AsSpan(0, _cursor));
renderer.SetTransform(
new Vector2(GlobalPosition.X + caretX + _padding.Left, GlobalPosition.Y + _padding.Top),
Vector2.Zero
);
var caretHeight = Math.Max(_placeholderSize.Height, _textSize.Height);
renderer.DrawRectangle(
new Vector2(1, caretHeight),
caretColor
);
}
}
protected override void OnUpdate()
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
var text = string.IsNullOrEmpty(_input) ? PlaceholderText : _input;
foreach (var c in text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
{
fontRef = fallbackFont;
}
}
if (fontRef.HasValue)
{
_suitableFont = fontRef;
var font = _suitableFont.Value;
_textSize = font.Measure(_input);
if (string.IsNullOrEmpty(_input))
_placeholderSize = font.Measure(PlaceholderText);
}
var size = Rect.MaxWidth(_placeholderSize, _textSize);
Size = _padding + size;
}
private float MeasureTextWidth(ReadOnlySpan<char> chars)
{
if (!_suitableFont.HasValue)
{
return 0.0f;
}
return _suitableFont.Value.Measure(chars).Width;
}
private float _repeatTimer = 0.0f;
private ActionType _lastActiveAction = ActionType.None;
private const float INITIAL_DELAY = 0.5f;
private const float REPEAT_RATE = 0.05f;
private string _input = string.Empty;
private bool _isFocused;
private int _cursor;
private bool _blink = true;
private ResourceRef<Font> _suitableFont = ResourceRef<Font>.Empty();
private Size _padding = Voile.Size.Zero;
private Rect _textSize = Rect.Zero;
private Rect _placeholderSize = Rect.Zero;
}