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