diff --git a/TestGame/Resources/default.style.toml b/TestGame/Resources/default.style.toml
index f588d3f..23c1d5a 100644
--- a/TestGame/Resources/default.style.toml
+++ b/TestGame/Resources/default.style.toml
@@ -58,3 +58,17 @@ BorderColor = "#161616"
# Default background color for all Container derived classes.
[Container]
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"
diff --git a/TestGame/TestGame.cs b/TestGame/TestGame.cs
index c8cb162..fc11cbb 100644
--- a/TestGame/TestGame.cs
+++ b/TestGame/TestGame.cs
@@ -73,7 +73,7 @@ public class TestGame : Game
_uiSystem.SetStyleSheet(_styleSheet);
- var addButton = new Button("Default button", _defaultFontSet);
+ var addButton = new Button("", _defaultFontSet);
var removeButton = new Button("Danger button", _defaultFontSet);
@@ -93,10 +93,16 @@ public class TestGame : Game
Anchor = Anchor.TopCenter
};
+ var inputField = new InputField(string.Empty, _defaultFontSet)
+ {
+ PlaceholderText = "Hello, World!"
+ };
+
c.AddChild(addButton);
c.AddChild(removeButton);
c.AddChild(outlineButton);
c.AddChild(linkButton);
+ c.AddChild(inputField);
var vc = new VerticalContainer(0.0f);
vc.AddChild(c);
diff --git a/Voile/Source/Input/InputSystem.cs b/Voile/Source/Input/InputSystem.cs
index 5683338..d1b3fb2 100644
--- a/Voile/Source/Input/InputSystem.cs
+++ b/Voile/Source/Input/InputSystem.cs
@@ -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.
///
public virtual void Poll() { }
-
public void Shutdown() => Dispose();
-
public void Dispose() => GC.SuppressFinalize(this);
-
public bool Handled { get => _handled; set => _handled = value; }
public bool IsActionDown(string action)
@@ -137,18 +134,20 @@ namespace Voile.Input
new KeyInputAction(KeyboardKey.Down),
]);
AddInputMapping("left", [
- new KeyInputAction(KeyboardKey.A),
new KeyInputAction(KeyboardKey.Left),
]);
AddInputMapping("right", [
- new KeyInputAction(KeyboardKey.D),
new KeyInputAction(KeyboardKey.Right),
]);
+
AddInputMapping("accept", [
new KeyInputAction(KeyboardKey.Enter),
]);
AddInputMapping("cancel", [
- new KeyInputAction(KeyboardKey.Backspace),
+ new KeyInputAction(KeyboardKey.Escape),
+ ]);
+ AddInputMapping("backspace", [
+ new KeyInputAction(KeyboardKey.Backspace)
]);
}
diff --git a/Voile/Source/Rect.cs b/Voile/Source/Rect.cs
index 224b7e0..b4113db 100644
--- a/Voile/Source/Rect.cs
+++ b/Voile/Source/Rect.cs
@@ -3,23 +3,61 @@ namespace Voile;
///
/// Represents a rectangle. Used to determine widget confines for UI layout.
///
-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 Height { get; set; } = Height;
+ 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 int CompareTo(Rect other) => 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;
+
+ ///
+ /// Returns the rectangle with the smallest width.
+ ///
+ ///
+ ///
+ ///
+ 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);
+ }
}
///
@@ -32,6 +70,8 @@ public struct Size : IEquatable
public float Top;
public float Bottom;
+ public static Size Zero => new Size(0.0f);
+
public Size(float uniform)
{
Left = Right = Top = Bottom = uniform;
@@ -51,8 +91,6 @@ public struct Size : IEquatable
Bottom = bottom;
}
- public static Size Zero => new Size(0);
-
public static Rect operator +(Size margin, Rect rect) =>
new Rect(rect.Width + margin.Left + margin.Right,
rect.Height + margin.Top + margin.Bottom);
diff --git a/Voile/Source/Rendering/RaylibRenderSystem.cs b/Voile/Source/Rendering/RaylibRenderSystem.cs
index fb8f189..bad8981 100644
--- a/Voile/Source/Rendering/RaylibRenderSystem.cs
+++ b/Voile/Source/Rendering/RaylibRenderSystem.cs
@@ -252,7 +252,27 @@ namespace Voile.Rendering
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)
diff --git a/Voile/Source/Resources/Font.cs b/Voile/Source/Resources/Font.cs
index 8747f20..47df04e 100644
--- a/Voile/Source/Resources/Font.cs
+++ b/Voile/Source/Resources/Font.cs
@@ -9,9 +9,14 @@ namespace Voile;
public struct Glyph
{
public int TextureId { get; set; } = -1;
+ public Vector2 Offset;
+
public float Width { get; set; }
public float Height { get; set; }
public Vector2 Bearing { get; set; }
+ ///
+ /// Glyph's advance in pixels.
+ ///
public int Advance { get; set; }
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 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 long BufferSize { get; set; }
@@ -87,7 +95,7 @@ public class Font : Resource, IUpdatableResource, IDisposable
char c = text[i];
Glyph glyph = GetGlyph(c);
- totalWidth += glyph.Advance;
+ totalWidth += glyph.Advance * SpacingScale + LetterSpacing;
float ascent = glyph.Bearing.Y;
float descent = glyph.Height - glyph.Bearing.Y;
@@ -112,10 +120,10 @@ public class Font : Resource, IUpdatableResource, IDisposable
{
unsafe
{
- if (FacePtr == IntPtr.Zero)
- return 0;
+ var face = (FT_FaceRec_*)FacePtr;
- FT_FaceRec_* face = (FT_FaceRec_*)FacePtr;
+ if (face == null)
+ return 0;
uint leftIndex = FT_Get_Char_Index(face, left);
uint rightIndex = FT_Get_Char_Index(face, right);
@@ -127,7 +135,7 @@ public class Font : Resource, IUpdatableResource, IDisposable
if (FT_Get_Kerning(face, leftIndex, rightIndex, FT_Kerning_Mode_.FT_KERNING_DEFAULT, &kerning) != 0)
return 0;
- return (int)kerning.x;
+ return (int)(kerning.x >> 6);
}
}
@@ -154,31 +162,48 @@ public class Font : Resource, IUpdatableResource, IDisposable
unsafe
{
var face = (FT_FaceRec_*)FacePtr;
+
+ if (FacePtr == IntPtr.Zero)
+ return false;
+
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)
{
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.
- FT_Error error = FT_Set_Pixel_Sizes(face, 0, (uint)Size);
- error = FT_Load_Char(face, character, FT_LOAD_NO_BITMAP);
+ var error = FT_Load_Char(face, character, FT_LOAD_RENDER);
if (error != 0)
throw new Exception($"Failed to load glyph for '{character}'");
- var glyph = face->glyph;
- var bitmap = glyph->bitmap;
- var metrics = glyph->metrics;
+ var g = face->glyph;
+ var bitmap = g->bitmap;
+ var metrics = g->metrics;
return new Glyph
{
- Width = metrics.width >> 6,
- Height = metrics.height >> 6,
- Bearing = new Vector2(metrics.horiBearingX >> 6, metrics.horiBearingY >> 6),
- Advance = (int)metrics.horiAdvance >> 6,
+ Width = bitmap.width,
+ Height = bitmap.rows,
+
+ Bearing = new Vector2(
+ metrics.horiBearingX >> 6,
+ metrics.horiBearingY >> 6
+ ),
+
+ Advance = (int)(g->advance.x >> 6)
};
}
diff --git a/Voile/Source/Resources/Loaders/FontLoader.cs b/Voile/Source/Resources/Loaders/FontLoader.cs
index af0da0d..8c30c00 100644
--- a/Voile/Source/Resources/Loaders/FontLoader.cs
+++ b/Voile/Source/Resources/Loaders/FontLoader.cs
@@ -21,11 +21,14 @@ public class FontLoader : ResourceLoader
byte[] fileBuffer = new byte[stream.Length];
int bytesRead = stream.Read(fileBuffer, 0, fileBuffer.Length);
- var result = new Font(path, fileBuffer);
+ var result = new Font(path, fileBuffer);
result.BufferSize = bytesRead;
LoadFaceData(result);
+
+ result.InitializeFontSize();
+
result.LoadAsciiData();
return result;
diff --git a/Voile/Source/UI/UIElement.cs b/Voile/Source/UI/UIElement.cs
index 810c5a4..8301e97 100644
--- a/Voile/Source/UI/UIElement.cs
+++ b/Voile/Source/UI/UIElement.cs
@@ -211,6 +211,7 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
var sb = new StringBuilder();
foreach (var modifier in modifiers)
{
+ if (string.IsNullOrEmpty(modifier)) continue;
sb.Append($".{modifier}");
}
diff --git a/Voile/Source/UI/UIInputContext.cs b/Voile/Source/UI/UIInputContext.cs
index 1c90375..6fa9bd3 100644
--- a/Voile/Source/UI/UIInputContext.cs
+++ b/Voile/Source/UI/UIInputContext.cs
@@ -17,15 +17,15 @@ public class UIInputContext
///
public Vector2 MousePosition { get; }
///
- /// Determines if a mouse button was pressed.
+ /// Determines if a left mouse button was pressed.
///
public bool MousePressed { get; set; }
///
- /// Determines if a mouse button was released.
+ /// Determines if a left mouse button was released.
///
public bool MouseReleased { get; set; }
///
- /// Determines if a mouse button is currently held.
+ /// Determines if a left mouse button is currently held.
///
public bool MouseDown { get; set; }
///
@@ -37,6 +37,29 @@ public class UIInputContext
///
public int CharPressed { get; }
+ ///
+ /// Determines if the current input action corresponds to a cancel operation (ESC on the keyboard, B on the Xbox controller, etc.)
+ ///
+ public bool IsCancel => ActionName == "cancel";
+ ///
+ /// Determines if the current input action corresponds to a backspace operation. Used for text inputs.
+ ///
+ public bool IsBackspace => ActionName == "backspace";
+ ///
+ /// Determines if the current input action corresponds to an accept/submit operation (ENTER on the keyboard, A on the Xbox controller, etc.)
+ ///
+ 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";
+
+ ///
+ /// Determines if control key is pressed.
+ ///
+ public bool Control { get; set; }
+
///
/// Determines if this registered any character input from keyboard.
///
diff --git a/Voile/Source/UI/UISystem.cs b/Voile/Source/UI/UISystem.cs
index a4b6aa8..a20cf72 100644
--- a/Voile/Source/UI/UISystem.cs
+++ b/Voile/Source/UI/UISystem.cs
@@ -122,7 +122,7 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
- MousePressed = _input.IsMouseButtonReleased(MouseButton.Left),
+ MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
};
if (PropagateInput(_elements, context))
@@ -135,6 +135,7 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed);
PropagateInput(_elements, context);
+ return;
}
@@ -147,7 +148,10 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
};
+
PropagateInput(_elements, context);
+
+ return;
}
}
@@ -157,20 +161,19 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{
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 (PropagateInput(parent.Children.ToList(), context))
return true;
}
+
+ if (element is IInputElement input && !input.IgnoreInput)
+ {
+ input.Input(context);
+
+ if (context.Handled)
+ return true;
+ }
}
return false;
diff --git a/Voile/Source/UI/Widgets/Button.cs b/Voile/Source/UI/Widgets/Button.cs
index 2214c98..68c62de 100644
--- a/Voile/Source/UI/Widgets/Button.cs
+++ b/Voile/Source/UI/Widgets/Button.cs
@@ -1,23 +1,23 @@
using System.Numerics;
using Voile.Rendering;
using Voile.Resources;
-using Voile.UI.Containers;
namespace Voile.UI.Widgets;
-public enum ButtonState
-{
- Disabled,
- Hovered,
- Pressed,
- Normal
-}
-
///
/// A clickable button with a label.
///
public class Button : Widget
{
+ public enum ButtonState
+ {
+ Disabled,
+ Hovered,
+ Pressed,
+ Focused,
+ Normal
+ }
+
public string Text
{
get => _text; set
@@ -93,7 +93,11 @@ public class Button : Widget
var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
- renderer.DrawText(_suitableFont, _text, textColor);
+
+ if (_suitableFont.HasValue)
+ {
+ renderer.DrawText(_suitableFont, _text, textColor);
+ }
}
protected virtual void Pressed() { }
@@ -138,6 +142,7 @@ public class Button : Widget
protected override void OnUpdate()
{
ResourceRef fontRef = ResourceRef.Empty();
+
foreach (var c in _text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
@@ -149,10 +154,13 @@ public class Button : Widget
}
}
- _suitableFont = fontRef;
+ if (fontRef.HasValue)
+ {
+ _suitableFont = fontRef;
- var font = _suitableFont.Value;
- _textSize = font.Measure(_text);
+ var font = _suitableFont.Value;
+ _textSize = font.Measure(_text);
+ }
Size = _padding + _textSize;
}
diff --git a/Voile/Source/UI/Widgets/InputField.cs b/Voile/Source/UI/Widgets/InputField.cs
new file mode 100644
index 0000000..4d396ce
--- /dev/null
+++ b/Voile/Source/UI/Widgets/InputField.cs
@@ -0,0 +1,230 @@
+using System.Numerics;
+using Voile.Rendering;
+using Voile.Resources;
+
+namespace Voile.UI.Widgets;
+
+public class InputField : Widget
+{
+ public override Rect MinimumSize => _placeholderSize + _padding;
+ public override string? StyleElementName => nameof(InputField);
+
+ public override string[]? StyleModifiers =>
+ [
+ _isFocused ? "Focused" : string.Empty
+ ];
+
+ ///
+ /// Current text value of this input field.
+ ///
+ 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();
+ }
+
+ protected override void OnInput(UIInputContext context)
+ {
+ HandleFocus(context);
+
+ if (!_isFocused)
+ return;
+
+ HandleTextInput(context);
+ HandleBackspace(context);
+ HandleCursorMovement(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 HandleBackspace(UIInputContext context)
+ {
+ if (!context.IsBackspace)
+ return;
+
+ if (_cursor <= 0 || _input.Length == 0)
+ return;
+
+ _input = _input.Remove(_cursor - 1, 1);
+ _cursor--;
+ MarkDirty();
+
+ context.SetHandled();
+ }
+
+ private void HandleCursorMovement(UIInputContext context)
+ {
+ if (context.IsLeft && _cursor > 0)
+ {
+ _cursor--;
+ MarkDirty();
+ context.SetHandled();
+ }
+
+ if (context.IsRight && _cursor < _input.Length)
+ {
+ _cursor++;
+ MarkDirty();
+ context.SetHandled();
+ }
+ }
+
+ 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.Substring(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 fontRef = ResourceRef.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);
+ _placeholderSize = font.Measure(PlaceholderText);
+ }
+
+ var size = Rect.MaxWidth(_placeholderSize, _textSize);
+
+ Size = _padding + size;
+ }
+
+ private float MeasureTextWidth(string text)
+ {
+ if (!_suitableFont.HasValue)
+ {
+ return 0.0f;
+ }
+
+ return _suitableFont.Value.Measure(text).Width;
+ }
+
+ private string _input = "";
+ private bool _isFocused;
+
+ private int _cursor;
+
+ private bool _blink = true;
+
+ private ResourceRef _suitableFont = ResourceRef.Empty();
+
+ private Size _padding = Voile.Size.Zero;
+ private Rect _textSize = Rect.Zero;
+ private Rect _placeholderSize = Rect.Zero;
+}
\ No newline at end of file