Compare commits
5 Commits
11423d86e5
...
2576ea87bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 2576ea87bc | |||
| 7bbfab8359 | |||
| 828ff3561b | |||
| c7acc70dcd | |||
| b506b78c32 |
@@ -1,12 +1,9 @@
|
||||
[Button]
|
||||
BackgroundColor = "#0f62fe"
|
||||
CornerRadius = 16.0
|
||||
TextColor = "#ffffff"
|
||||
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]
|
||||
BackgroundColor = "#0353e9"
|
||||
|
||||
@@ -61,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"
|
||||
|
||||
@@ -20,7 +20,7 @@ public class TestGame : Game
|
||||
InitializeSystemsDefault();
|
||||
|
||||
_uiSystem = new UISystem(Input);
|
||||
// _uiSystem.RenderDebugRects = true;
|
||||
_uiSystem.RenderDebugRects = true;
|
||||
|
||||
ResourceManager.EnableFileWatching();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
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;
|
||||
|
||||
float totalWidth = 0;
|
||||
float maxAscent = 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);
|
||||
|
||||
totalWidth += glyph.Advance;
|
||||
totalWidth += glyph.Advance * SpacingScale + LetterSpacing;
|
||||
|
||||
float ascent = glyph.Bearing.Y;
|
||||
float descent = glyph.Height - glyph.Bearing.Y;
|
||||
@@ -99,7 +117,7 @@ public class Font : Resource, IUpdatableResource, IDisposable
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
char prevChar = text[i - 1];
|
||||
char prevChar = chars[i - 1];
|
||||
totalWidth += GetKerning(prevChar, c);
|
||||
}
|
||||
}
|
||||
@@ -112,10 +130,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 +145,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 +172,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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Numerics;
|
||||
using Voile.Input;
|
||||
using Voile.Rendering;
|
||||
|
||||
namespace Voile.UI;
|
||||
@@ -18,6 +19,19 @@ public interface IElement
|
||||
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>
|
||||
/// Represents a UI element that can contain child elements.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Voile.UI;
|
||||
/// </summary>
|
||||
public class Style
|
||||
{
|
||||
public enum AnimationType
|
||||
public enum TransitionType
|
||||
{
|
||||
Linear,
|
||||
EaseIn,
|
||||
@@ -20,7 +20,7 @@ public class Style
|
||||
}
|
||||
|
||||
public float TransitionDuration = 0f;
|
||||
public AnimationType TransitionType = AnimationType.Linear;
|
||||
public TransitionType Transition = TransitionType.Linear;
|
||||
|
||||
public Style() { }
|
||||
|
||||
@@ -35,17 +35,39 @@ public class Style
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
/// <param name="overrideStyle"></param>
|
||||
/// <param name="other"></param>
|
||||
/// <returns>A merged <see cref="Style"/>.</returns>
|
||||
public Style Merge(Style overrideStyle)
|
||||
public Style Merge(Style other)
|
||||
{
|
||||
return new Style
|
||||
{
|
||||
BackgroundColor = overrideStyle.BackgroundColor != default ? overrideStyle.BackgroundColor : BackgroundColor,
|
||||
TextColor = overrideStyle.TextColor != default ? overrideStyle.TextColor : TextColor,
|
||||
Padding = overrideStyle.Padding != default ? overrideStyle.Padding : Padding,
|
||||
BorderSize = overrideStyle.BorderSize != default ? overrideStyle.BorderSize : BorderSize,
|
||||
BorderColor = overrideStyle.BorderColor != default ? overrideStyle.BorderColor : BorderColor,
|
||||
Padding =
|
||||
other.Padding ?? Padding,
|
||||
|
||||
BackgroundColor =
|
||||
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();
|
||||
|
||||
string easingName = reader.GetString("TransitionType", "Linear");
|
||||
string transitionName = reader.GetString("TransitionType", "Linear");
|
||||
|
||||
if (!Enum.TryParse<Style.AnimationType>(easingName, true, out var easing))
|
||||
easing = Style.AnimationType.Linear;
|
||||
if (!Enum.TryParse<Style.TransitionType>(transitionName, true, out var transition))
|
||||
throw new ArgumentException($"\"{transitionName}\" is not a valid TransitionType.");
|
||||
|
||||
style.TransitionType = easing;
|
||||
style.Transition = transition;
|
||||
|
||||
|
||||
if (reader.HasKey("BackgroundColor"))
|
||||
@@ -147,9 +169,13 @@ public class StyleSheet : Resource
|
||||
|
||||
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>()
|
||||
@@ -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();
|
||||
}
|
||||
@@ -13,14 +13,14 @@ public class StyleAnimator
|
||||
_elapsed = 0f;
|
||||
}
|
||||
|
||||
public static float Ease(float t, Style.AnimationType type)
|
||||
public static float Ease(float t, Style.TransitionType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
Style.AnimationType.Linear => t,
|
||||
Style.AnimationType.EaseIn => t * t,
|
||||
Style.AnimationType.EaseOut => t * (2 - t),
|
||||
Style.AnimationType.EaseInOut => t < 0.5f
|
||||
Style.TransitionType.Linear => t,
|
||||
Style.TransitionType.EaseIn => t * t,
|
||||
Style.TransitionType.EaseOut => t * (2 - t),
|
||||
Style.TransitionType.EaseInOut => t < 0.5f
|
||||
? 2 * t * t
|
||||
: -1 + (4 - 2 * t) * t,
|
||||
_ => t
|
||||
@@ -31,7 +31,7 @@ public class StyleAnimator
|
||||
{
|
||||
_elapsed = MathF.Min(_elapsed + deltaTime, _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);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class StyleAnimator
|
||||
Padding = MathUtils.LerpSize(from.Padding ?? Size.Zero, to.Padding ?? Size.Zero, 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),
|
||||
TransitionType = to.TransitionType
|
||||
Transition = to.Transition
|
||||
};
|
||||
|
||||
return result;
|
||||
|
||||
@@ -42,24 +42,23 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
|
||||
get => _size;
|
||||
set
|
||||
{
|
||||
if (value.Width < MinimumSize.Width)
|
||||
{
|
||||
_size.Width = MinimumSize.Width;
|
||||
}
|
||||
float width = Math.Max(value.Width, MinimumSize.Width);
|
||||
float height = Math.Max(value.Height, MinimumSize.Height);
|
||||
|
||||
if (value.Height < MinimumSize.Height)
|
||||
{
|
||||
_size.Height = MinimumSize.Height;
|
||||
}
|
||||
bool changed =
|
||||
_size.Width != width ||
|
||||
_size.Height != height;
|
||||
|
||||
if (!changed)
|
||||
return;
|
||||
|
||||
_size.Width = width;
|
||||
_size.Height = height;
|
||||
|
||||
if (_size != value)
|
||||
{
|
||||
MarkDirty();
|
||||
}
|
||||
}
|
||||
|
||||
_size = value;
|
||||
}
|
||||
}
|
||||
public Vector2 AnchorOffset { get; set; } = Vector2.Zero;
|
||||
public Anchor Anchor { get; set; } = Anchor.TopLeft;
|
||||
|
||||
@@ -211,6 +210,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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,31 @@ 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";
|
||||
|
||||
public float DeltaTime { get; init; }
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -39,21 +39,19 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
|
||||
|
||||
public void Update(double deltaTime)
|
||||
{
|
||||
// HandleInput();
|
||||
}
|
||||
|
||||
public void Render(RenderSystem renderer)
|
||||
{
|
||||
// Update elements each time UI system is rendered.
|
||||
HandleInput();
|
||||
float dt = (float)deltaTime;
|
||||
PropagateTick(_elements, dt);
|
||||
HandleInput((float)deltaTime);
|
||||
|
||||
foreach (var element in _elements)
|
||||
{
|
||||
if (element is not IUpdatableElement updatable) continue;
|
||||
updatable.Update();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void Render(RenderSystem renderer)
|
||||
{
|
||||
foreach (var element in _elements)
|
||||
{
|
||||
if (element is IRenderableElement renderable)
|
||||
@@ -104,50 +102,90 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleInput()
|
||||
private void HandleInput(float deltaTime)
|
||||
{
|
||||
int charPressed = _input.GetCharPressed();
|
||||
Vector2 mousePos = _input.GetMousePosition();
|
||||
|
||||
Vector2 currentMousePosition = _input.GetMousePosition();
|
||||
bool inputHandled = false;
|
||||
|
||||
// Process Actions (Pressed or Held)
|
||||
foreach (var (actionName, mappings) in InputSystem.InputMappings)
|
||||
{
|
||||
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)
|
||||
{
|
||||
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
|
||||
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
|
||||
MousePressed = _input.IsMouseButtonReleased(MouseButton.Left),
|
||||
MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
|
||||
DeltaTime = deltaTime
|
||||
};
|
||||
|
||||
if (PropagateInput(_elements, context))
|
||||
{
|
||||
inputHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inputHandled) break;
|
||||
}
|
||||
|
||||
if (inputHandled)
|
||||
{
|
||||
_lastMousePosition = currentMousePosition;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (charPressed != 0)
|
||||
{
|
||||
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed);
|
||||
PropagateInput(_elements, context);
|
||||
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed)
|
||||
{
|
||||
DeltaTime = deltaTime
|
||||
};
|
||||
if (PropagateInput(_elements, context))
|
||||
{
|
||||
_lastMousePosition = currentMousePosition;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (currentMousePosition != _lastMousePosition)
|
||||
{
|
||||
// TODO: specify which mouse button is used in the context.
|
||||
var context = new UIInputContext(new MouseInputAction(MouseButton.Left), mousePos, "", charPressed)
|
||||
var frameContext = new UIInputContext(new MouseInputAction(MouseButton.Left), mousePos, "", charPressed)
|
||||
{
|
||||
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
|
||||
MouseReleased = _input.IsMouseButtonReleased(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];
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
289
Voile/Source/UI/Widgets/InputField.cs
Normal file
289
Voile/Source/UI/Widgets/InputField.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user