Input fields and refactors required to support them

This commit is contained in:
2026-05-31 22:14:25 +02:00
parent b506b78c32
commit c7acc70dcd
12 changed files with 431 additions and 61 deletions

View File

@@ -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"

View File

@@ -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);

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.
/// </summary>
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)
]);
}

View File

@@ -3,23 +3,61 @@ namespace Voile;
/// <summary>
/// Represents a rectangle. Used to determine widget confines for UI layout.
/// </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 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;
/// <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>
@@ -32,6 +70,8 @@ public struct Size : IEquatable<Size>
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<Size>
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);

View File

@@ -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)

View File

@@ -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; }
/// <summary>
/// Glyph's advance in pixels.
/// </summary>
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.
/// </summary>
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)
};
}

View File

@@ -21,11 +21,14 @@ public class FontLoader : ResourceLoader<Font>
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;

View File

@@ -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}");
}

View File

@@ -17,15 +17,15 @@ public class UIInputContext
/// </summary>
public Vector2 MousePosition { get; }
/// <summary>
/// Determines if a mouse button was pressed.
/// Determines if a left mouse button was pressed.
/// </summary>
public bool MousePressed { get; set; }
/// <summary>
/// Determines if a mouse button was released.
/// Determines if a left mouse button was released.
/// </summary>
public bool MouseReleased { get; set; }
/// <summary>
/// Determines if a mouse button is currently held.
/// Determines if a left mouse button is currently held.
/// </summary>
public bool MouseDown { get; set; }
/// <summary>
@@ -37,6 +37,29 @@ public class UIInputContext
/// </summary>
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";
/// <summary>
/// Determines if control key is pressed.
/// </summary>
public bool Control { get; set; }
/// <summary>
/// Determines if this <see cref="UIInputContext"/> registered any character input from keyboard.
/// </summary>

View File

@@ -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;

View File

@@ -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
}
/// <summary>
/// A clickable button with a label.
/// </summary>
public class Button : Widget
{
public enum ButtonState
{
Disabled,
Hovered,
Pressed,
Focused,
Normal
}
public string Text
{
get => _text; set
@@ -93,8 +93,12 @@ public class Button : Widget
var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
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<Font> fontRef = ResourceRef<Font>.Empty();
foreach (var c in _text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
@@ -149,10 +154,13 @@ public class Button : Widget
}
}
if (fontRef.HasValue)
{
_suitableFont = fontRef;
var font = _suitableFont.Value;
_textSize = font.Measure(_text);
}
Size = _padding + _textSize;
}

View File

@@ -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
];
/// <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();
}
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<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);
_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<Font> _suitableFont = ResourceRef<Font>.Empty();
private Size _padding = Voile.Size.Zero;
private Rect _textSize = Rect.Zero;
private Rect _placeholderSize = Rect.Zero;
}