diff --git a/CHANGELOG.md b/CHANGELOG.md
index 289bd44..35d1a04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ Additions
- Added ReverseInput, ReverseOutput and AndThen to Easings
- Added an Enum constructor to GenericInput
- Added RandomPitchModifier and GetRandomPitch to SoundEffectInfo
+- Added TextInput class, which is an isolated version of MLEM.Ui's TextField logic
Improvements
- Allow comparing Keybind and Combination based on the amount of modifiers they have
@@ -356,4 +357,4 @@ Additions
Fixes
- Fixed some number parsing not using the invariant culture
-- Fixed RawContentManager crashing with dynamic assemblies present
\ No newline at end of file
+- Fixed RawContentManager crashing with dynamic assemblies present
diff --git a/MLEM.Ui/Elements/TextField.cs b/MLEM.Ui/Elements/TextField.cs
index 325e013..d319b97 100644
--- a/MLEM.Ui/Elements/TextField.cs
+++ b/MLEM.Ui/Elements/TextField.cs
@@ -1,11 +1,5 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Text;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
-using Microsoft.Xna.Framework.Input;
-using MLEM.Extensions;
using MLEM.Font;
using MLEM.Graphics;
using MLEM.Input;
@@ -19,57 +13,22 @@ namespace MLEM.Ui.Elements {
/// A text field element for use inside of a .
/// A text field is a selectable element that can be typed in, as well as copied and pasted from.
/// If an on-screen keyboard is required, then this text field will automatically open an on-screen keyboard using .
+ /// This class interally uses MLEM's .
///
public class TextField : Element {
- ///
- /// A that allows any visible character and spaces
- ///
- public static readonly Rule DefaultRule = (field, add) => {
- foreach (var c in add) {
- if (char.IsControl(c) && (!field.Multiline || c != '\n'))
- return false;
- }
- return true;
- };
- ///
- /// A that only allows letters
- ///
- public static readonly Rule OnlyLetters = (field, add) => {
- foreach (var c in add) {
- if (!char.IsLetter(c))
- return false;
- }
- return true;
- };
- ///
- /// A that only allows numerals
- ///
- public static readonly Rule OnlyNumbers = (field, add) => {
- foreach (var c in add) {
- if (!char.IsNumber(c))
- return false;
- }
- return true;
- };
- ///
- /// A that only allows letters and numerals
- ///
- public static readonly Rule LettersNumbers = (field, add) => {
- foreach (var c in add) {
- if (!char.IsLetter(c) || !char.IsNumber(c))
- return false;
- }
- return true;
- };
- ///
- /// A that only allows characters not contained in
- ///
- public static readonly Rule PathNames = (field, add) => add.IndexOfAny(Path.GetInvalidPathChars()) < 0;
- ///
- /// A that only allows characters not contained in
- ///
- public static readonly Rule FileNames = (field, add) => add.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
+ ///
+ public static readonly Rule DefaultRule = (field, add) => TextInput.DefaultRule(field.textInput, add);
+ ///
+ public static readonly Rule OnlyLetters = (field, add) => TextInput.OnlyLetters(field.textInput, add);
+ ///
+ public static readonly Rule OnlyNumbers = (field, add) => TextInput.OnlyNumbers(field.textInput, add);
+ ///
+ public static readonly Rule LettersNumbers = (field, add) => TextInput.LettersNumbers(field.textInput, add);
+ ///
+ public static readonly Rule PathNames = (field, add) => TextInput.PathNames(field.textInput, add);
+ ///
+ public static readonly Rule FileNames = (field, add) => TextInput.FileNames(field.textInput, add);
///
/// The color that this text field's text should display with
@@ -94,23 +53,23 @@ namespace MLEM.Ui.Elements {
///
/// The scale that this text field should render text with
///
- public StyleProp TextScale;
+ public StyleProp TextScale {
+ get => this.textScale;
+ set {
+ this.textScale = value;
+ this.textInput.TextScale = value;
+ }
+ }
///
/// The font that this text field should display text with
///
- public StyleProp Font;
- ///
- /// This text field's current text
- ///
- public string Text => this.text.ToString();
- ///
- /// The text that displays in this text field if is empty
- ///
- public string PlaceholderText;
- ///
- /// An event that gets called when changes, either through input, or through a manual change.
- ///
- public TextChanged OnTextChange;
+ public StyleProp Font {
+ get => this.font;
+ set {
+ this.font = value;
+ this.textInput.Font = value;
+ }
+ }
///
/// The x position that text should start rendering at, based on the x position of this text field.
///
@@ -119,11 +78,42 @@ namespace MLEM.Ui.Elements {
/// The width that the caret should render with, in pixels
///
public StyleProp CaretWidth;
- ///
- /// The rule used for text input.
- /// Rules allow only certain characters to be allowed inside of a text field.
- ///
+
+ ///
+ public string Text => this.textInput.Text;
+ ///
+ public TextChanged OnTextChange;
+ ///
public Rule InputRule;
+ ///
+ public int CaretPos {
+ get => this.textInput.CaretPos;
+ set => this.textInput.CaretPos = value;
+ }
+ ///
+ public int CaretLine => this.textInput.CaretLine;
+ ///
+ public int CaretPosInLine => this.textInput.CaretPosInLine;
+ ///
+ public char? MaskingCharacter {
+ get => this.textInput.MaskingCharacter;
+ set => this.textInput.MaskingCharacter = value;
+ }
+ ///
+ public int? MaximumCharacters {
+ get => this.textInput.MaximumCharacters;
+ set => this.textInput.MaximumCharacters = value;
+ }
+ ///
+ public bool Multiline {
+ get => this.textInput.Multiline;
+ set => this.textInput.Multiline = value;
+ }
+
+ ///
+ /// The text that displays in this text field if is empty
+ ///
+ public string PlaceholderText;
///
/// The title of the KeyboardInput field on mobile devices and consoles
///
@@ -132,72 +122,10 @@ namespace MLEM.Ui.Elements {
/// The description of the KeyboardInput field on mobile devices and consoles
///
public string MobileDescription;
- ///
- /// The position of the caret within the text.
- /// This is always between 0 and the of
- ///
- public int CaretPos {
- get => this.caretPos;
- set {
- var val = MathHelper.Clamp(value, 0, this.text.Length);
- if (this.caretPos != val) {
- this.caretPos = val;
- this.caretBlinkTimer = 0;
- this.HandleTextChange(false);
- }
- }
- }
- ///
- /// The line of text that the caret is currently on.
- /// This can only be only non-0 if is true.
- ///
- public int CaretLine { get; private set; }
- ///
- /// The position in the current that the caret is currently on.
- /// If is false, this value is always equal to .
- ///
- public int CaretPosInLine { get; private set; }
- ///
- /// A character that should be displayed instead of this text field's content.
- /// The amount of masking characters displayed will be equal to the 's length.
- /// This behavior is useful for password fields or similar.
- ///
- public char? MaskingCharacter {
- get => this.maskingCharacter;
- set {
- this.maskingCharacter = value;
- this.HandleTextChange(false);
- }
- }
- ///
- /// The maximum amount of characters that can be input into this text field.
- /// If this is set, the length of will never exceed this value.
- ///
- public int? MaximumCharacters;
- ///
- /// Whether this text field should support multi-line editing.
- /// If this is true, pressing will insert a new line into the if the allows it.
- /// Additionally, text will be rendered with horizontal soft wraps, and lines that are outside of the text field's bounds will be hidden.
- ///
- public bool Multiline {
- get => this.multiline;
- set {
- this.multiline = value;
- this.HandleTextChange(false);
- }
- }
- private readonly StringBuilder text = new StringBuilder();
-
- private char? maskingCharacter;
- private double caretBlinkTimer;
- private string displayedText;
- private string[] splitText;
- private int textOffset;
- private int lineOffset;
- private int caretPos;
- private float caretDrawOffset;
- private bool multiline;
+ private readonly TextInput textInput;
+ private StyleProp font;
+ private StyleProp textScale;
///
/// Creates a new text field with the given settings
@@ -209,6 +137,11 @@ namespace MLEM.Ui.Elements {
/// The text that the text field should contain by default
/// Whether the text field should support multi-line editing
public TextField(Anchor anchor, Vector2 size, Rule rule = null, GenericFont font = null, string text = null, bool multiline = false) : base(anchor, size) {
+ this.textInput = new TextInput(null, Vector2.Zero, 1, null, ClipboardService.SetText, ClipboardService.GetText) {
+ OnTextChange = (i, s) => this.OnTextChange?.Invoke(this, s),
+ InputRule = (i, s) => this.InputRule.Invoke(this, s)
+ };
+
this.InputRule = rule ?? TextField.DefaultRule;
this.Multiline = multiline;
if (font != null)
@@ -218,73 +151,32 @@ namespace MLEM.Ui.Elements {
MlemPlatform.EnsureExists();
- this.OnPressed += OnPressed;
- this.OnTextInput += (element, key, character) => {
- if (!this.IsSelected || this.IsHidden)
- return;
- if (key == Keys.Back) {
- if (this.CaretPos > 0) {
- this.CaretPos--;
- this.RemoveText(this.CaretPos, 1);
- }
- } else if (key == Keys.Delete) {
- this.RemoveText(this.CaretPos, 1);
- } else if (this.Multiline && key == Keys.Enter) {
- this.InsertText('\n');
- } else {
- this.InsertText(character);
- }
- };
- this.OnDeselected += e => this.CaretPos = 0;
- this.OnSelected += e => this.CaretPos = this.text.Length;
-
- async void OnPressed(Element e) {
+ this.OnPressed += async e => {
var title = this.MobileTitle ?? this.PlaceholderText;
var result = await MlemPlatform.Current.OpenOnScreenKeyboard(title, this.MobileDescription, this.Text, false);
if (result != null)
this.SetText(this.Multiline ? result : result.Replace('\n', ' '), true);
- }
+ };
+ this.OnTextInput += (element, key, character) => {
+ if (this.IsSelected && !this.IsHidden)
+ this.textInput.OnTextInput(key, character);
+ };
+ this.OnDeselected += e => this.CaretPos = 0;
+ this.OnSelected += e => this.CaretPos = this.textInput.Length;
+ }
+
+ ///
+ public override void SetAreaAndUpdateChildren(RectangleF area) {
+ base.SetAreaAndUpdateChildren(area);
+ this.textInput.Size = this.DisplayArea.Size / this.Scale - new Vector2(2 * this.TextOffsetX);
+ this.textInput.TextScale = this.TextScale;
}
///
public override void Update(GameTime time) {
base.Update(time);
-
- // handle first initialization if not done
- if (this.displayedText == null)
- this.HandleTextChange(false);
-
- if (!this.IsSelected || this.IsHidden)
- return;
-
- if (this.CaretPos > 0 && this.Input.TryConsumePressed(Keys.Left)) {
- this.CaretPos--;
- } else if (this.CaretPos < this.text.Length && this.Input.TryConsumePressed(Keys.Right)) {
- this.CaretPos++;
- } else if (this.Multiline && this.Input.IsKeyPressedAvailable(Keys.Up) && this.MoveCaretToLine(this.CaretLine - 1)) {
- this.Input.TryConsumeKeyPressed(Keys.Up);
- } else if (this.Multiline && this.Input.IsKeyPressedAvailable(Keys.Down) && this.MoveCaretToLine(this.CaretLine + 1)) {
- this.Input.TryConsumeKeyPressed(Keys.Down);
- } else if (this.CaretPos != 0 && this.Input.TryConsumeKeyPressed(Keys.Home)) {
- this.CaretPos = 0;
- } else if (this.CaretPos != this.text.Length && this.Input.TryConsumeKeyPressed(Keys.End)) {
- this.CaretPos = this.text.Length;
- } else if (this.Input.IsModifierKeyDown(ModifierKey.Control)) {
- if (this.Input.IsKeyPressedAvailable(Keys.V)) {
- var clip = ClipboardService.GetText();
- if (clip != null) {
- this.InsertText(clip, true);
- this.Input.TryConsumeKeyPressed(Keys.V);
- }
- } else if (this.Input.TryConsumeKeyPressed(Keys.C)) {
- // until there is text selection, just copy the whole content
- ClipboardService.SetText(this.Text);
- }
- }
-
- this.caretBlinkTimer += time.ElapsedGameTime.TotalSeconds;
- if (this.caretBlinkTimer >= 1)
- this.caretBlinkTimer = 0;
+ if (this.IsSelected && !this.IsHidden)
+ this.textInput.Update(time, this.Input);
}
///
@@ -297,101 +189,31 @@ namespace MLEM.Ui.Elements {
}
batch.Draw(tex, this.DisplayArea, color, this.Scale);
- if (this.displayedText != null) {
- var lineHeight = this.Font.Value.LineHeight * this.TextScale * this.Scale;
- var offset = new Vector2(
- this.TextOffsetX * this.Scale,
- this.Multiline ? this.TextOffsetX * this.Scale : this.DisplayArea.Height / 2 - lineHeight / 2);
- var textPos = this.DisplayArea.Location + offset;
- if (this.text.Length > 0 || this.IsSelected) {
- var textColor = this.TextColor.OrDefault(Color.White);
- this.Font.Value.DrawString(batch, this.displayedText, textPos, textColor * alpha, 0, Vector2.Zero, this.TextScale * this.Scale, SpriteEffects.None, 0);
-
- if (this.IsSelected && this.caretBlinkTimer < 0.5F) {
- var caretDrawPos = textPos + new Vector2(this.caretDrawOffset * this.TextScale * this.Scale, 0);
- if (this.Multiline)
- caretDrawPos.Y += this.Font.Value.LineHeight * (this.CaretLine - this.lineOffset) * this.TextScale * this.Scale;
- batch.Draw(batch.GetBlankTexture(), new RectangleF(caretDrawPos, new Vector2(this.CaretWidth * this.Scale, lineHeight)), null, textColor * alpha);
- }
- } else if (this.PlaceholderText != null) {
- this.Font.Value.DrawString(batch, this.PlaceholderText, textPos, this.PlaceholderColor.OrDefault(Color.Gray) * alpha, 0, Vector2.Zero, this.TextScale * this.Scale, SpriteEffects.None, 0);
- }
+ var lineHeight = this.Font.Value.LineHeight * this.TextScale * this.Scale;
+ var textPos = this.DisplayArea.Location + new Vector2(
+ this.TextOffsetX * this.Scale,
+ this.Multiline ? this.TextOffsetX * this.Scale : this.DisplayArea.Height / 2 - lineHeight / 2);
+ if (this.textInput.Length > 0 || this.IsSelected) {
+ this.textInput.Draw(batch, textPos, this.Scale, this.IsSelected ? this.CaretWidth : 0, this.TextColor.OrDefault(Color.White) * alpha);
+ } else if (this.PlaceholderText != null) {
+ this.Font.Value.DrawString(batch, this.PlaceholderText, textPos, this.PlaceholderColor.OrDefault(Color.Gray) * alpha, 0, Vector2.Zero, this.TextScale * this.Scale, SpriteEffects.None, 0);
}
base.Draw(time, batch, alpha, context);
}
- ///
- /// Replaces this text field's text with the given text.
- /// If the resulting exceeds , the end will be cropped to fit.
- ///
- /// The new text
- /// If any characters that don't match the should be left out
+ ///
public void SetText(object text, bool removeMismatching = false) {
- var strg = text?.ToString() ?? string.Empty;
- if (!this.FilterText(ref strg, removeMismatching))
- return;
- if (this.MaximumCharacters != null && strg.Length > this.MaximumCharacters)
- strg = strg.Substring(0, this.MaximumCharacters.Value);
- this.text.Clear();
- this.text.Append(strg);
- this.CaretPos = this.text.Length;
- this.HandleTextChange();
+ this.textInput.SetText(text, removeMismatching);
}
- ///
- /// Inserts the given text at the .
- /// If the resulting exceeds , the end will be cropped to fit.
- ///
- /// The text to insert
- /// If any characters that don't match the should be left out
+ ///
public void InsertText(object text, bool removeMismatching = false) {
- var strg = text?.ToString() ?? string.Empty;
- if (!this.FilterText(ref strg, removeMismatching))
- return;
- if (this.MaximumCharacters != null && this.text.Length + strg.Length > this.MaximumCharacters)
- strg = strg.Substring(0, this.MaximumCharacters.Value - this.text.Length);
- this.text.Insert(this.CaretPos, strg);
- this.CaretPos += strg.Length;
- this.HandleTextChange();
+ this.textInput.InsertText(text, removeMismatching);
}
- ///
- /// Removes the given amount of text at the given index
- ///
- /// The index
- /// The amount of text to remove
+ ///
public void RemoveText(int index, int length) {
- if (index < 0 || index >= this.text.Length)
- return;
- this.text.Remove(index, length);
- // ensure that caret pos is still in bounds
- this.CaretPos = this.CaretPos;
- this.HandleTextChange();
- }
-
- ///
- /// Moves the to the given line, if it exists.
- /// Additionally maintains the roughly based on the visual distance that the caret has from the left border of the current .
- ///
- /// The line to move the caret to
- /// True if the caret was moved, false if it was not (which indicates that the line with the given index does not exist)
- public bool MoveCaretToLine(int line) {
- var (destStart, destEnd) = this.GetLineBounds(line);
- if (destEnd > 0) {
- // find the position whose distance from the start is closest to the current distance from the start
- var destAccum = "";
- while (destAccum.Length < destEnd - destStart) {
- if (this.Font.Value.MeasureString(destAccum).X >= this.caretDrawOffset) {
- this.CaretPos = destStart + destAccum.Length;
- return true;
- }
- destAccum += this.text[destStart + destAccum.Length];
- }
- // if we don't find a proper position, just move to the end of the destination line
- this.CaretPos = destEnd;
- return true;
- }
- return false;
+ this.textInput.RemoveText(index, length);
}
///
@@ -406,154 +228,6 @@ namespace MLEM.Ui.Elements {
this.CaretWidth = this.CaretWidth.OrStyle(style.TextFieldCaretWidth);
}
- private bool FilterText(ref string text, bool removeMismatching) {
- if (removeMismatching) {
- var result = new StringBuilder();
- foreach (var c in text) {
- if (this.InputRule(this, c.ToCachedString()))
- result.Append(c);
- }
- text = result.ToString();
- } else if (!this.InputRule(this, text))
- return false;
- return true;
- }
-
- private void HandleTextChange(bool textChanged = true) {
- // not initialized yet
- if (!this.Font.HasValue())
- return;
- var maxWidth = this.DisplayArea.Width / this.Scale - this.TextOffsetX * 2;
- if (this.Multiline) {
- // soft wrap if we're multiline
- this.splitText = this.Font.Value.SplitStringSeparate(this.text, maxWidth, this.TextScale).ToArray();
- this.displayedText = string.Join("\n", this.splitText);
- this.UpdateCaretData();
-
- var maxHeight = this.DisplayArea.Height / this.Scale - this.TextOffsetX * 2;
- if (this.Font.Value.MeasureString(this.displayedText).Y * this.TextScale > maxHeight) {
- var maxLines = (maxHeight / (this.Font.Value.LineHeight * this.TextScale)).Floor();
- if (this.lineOffset > this.CaretLine) {
- // if we're moving up
- this.lineOffset = this.CaretLine;
- } else if (this.CaretLine >= maxLines) {
- // if we're moving down
- var limit = this.CaretLine - (maxLines - 1);
- if (limit > this.lineOffset)
- this.lineOffset = limit;
- }
- // calculate resulting string
- var ret = new StringBuilder();
- var lines = 0;
- var originalIndex = 0;
- for (var i = 0; i < this.displayedText.Length; i++) {
- if (lines >= this.lineOffset) {
- if (ret.Length <= 0)
- this.textOffset = originalIndex;
- ret.Append(this.displayedText[i]);
- }
- if (this.displayedText[i] == '\n') {
- lines++;
- if (this.text[originalIndex] == '\n')
- originalIndex++;
- } else {
- originalIndex++;
- }
- if (lines - this.lineOffset >= maxLines)
- break;
- }
- this.displayedText = ret.ToString();
- } else {
- this.lineOffset = 0;
- this.textOffset = 0;
- }
- } else {
- // not multiline, so scroll horizontally based on caret position
- if (this.Font.Value.MeasureString(this.text).X * this.TextScale > maxWidth) {
- if (this.textOffset > this.CaretPos) {
- // if we're moving the caret to the left
- this.textOffset = this.CaretPos;
- } else {
- // if we're moving the caret to the right
- var importantArea = this.text.ToString(this.textOffset, Math.Min(this.CaretPos, this.text.Length) - this.textOffset);
- var bound = this.CaretPos - this.Font.Value.TruncateString(importantArea, maxWidth, this.TextScale, true).Length;
- if (this.textOffset < bound)
- this.textOffset = bound;
- }
- var visible = this.text.ToString(this.textOffset, this.text.Length - this.textOffset);
- this.displayedText = this.Font.Value.TruncateString(visible, maxWidth, this.TextScale);
- } else {
- this.displayedText = this.Text;
- this.textOffset = 0;
- }
- this.UpdateCaretData();
- }
-
- if (this.MaskingCharacter != null)
- this.displayedText = new string(this.MaskingCharacter.Value, this.displayedText.Length);
-
- if (textChanged)
- this.OnTextChange?.Invoke(this, this.Text);
- }
-
- private void UpdateCaretData() {
- if (this.splitText != null) {
- var line = 0;
- var index = 0;
- for (var d = 0; d < this.splitText.Length; d++) {
- var startOfLine = 0;
- var split = this.splitText[d];
- for (var i = 0; i <= split.Length; i++) {
- if (index == this.CaretPos) {
- this.CaretLine = line;
- this.CaretPosInLine = i - startOfLine;
- this.caretDrawOffset = this.Font.Value.MeasureString(split.Substring(startOfLine, this.CaretPosInLine)).X;
- return;
- }
- if (i < split.Length) {
- // manual splits
- if (split[i] == '\n') {
- startOfLine = i + 1;
- line++;
- }
- index++;
- }
- }
- // max width splits
- line++;
- }
- } else if (this.displayedText != null) {
- this.CaretLine = 0;
- this.CaretPosInLine = this.CaretPos;
- this.caretDrawOffset = this.Font.Value.MeasureString(this.displayedText.Substring(0, this.CaretPos - this.textOffset)).X;
- }
- }
-
- private (int, int) GetLineBounds(int boundLine) {
- if (this.splitText != null) {
- var line = 0;
- var index = 0;
- var startOfLineIndex = 0;
- for (var d = 0; d < this.splitText.Length; d++) {
- var split = this.splitText[d];
- for (var i = 0; i < split.Length; i++) {
- index++;
- if (split[i] == '\n') {
- if (boundLine == line)
- return (startOfLineIndex, index - 1);
- line++;
- startOfLineIndex = index;
- }
- }
- if (boundLine == line)
- return (startOfLineIndex, index - 1);
- line++;
- startOfLineIndex = index;
- }
- }
- return default;
- }
-
///
/// A delegate method used for
///
diff --git a/MLEM/Input/TextInput.cs b/MLEM/Input/TextInput.cs
new file mode 100644
index 0000000..331b519
--- /dev/null
+++ b/MLEM/Input/TextInput.cs
@@ -0,0 +1,548 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using Microsoft.Xna.Framework.Input;
+using MLEM.Extensions;
+using MLEM.Font;
+using MLEM.Misc;
+
+namespace MLEM.Input {
+ ///
+ /// A class that contains all of the necessary tools to create a text input field or box.
+ /// This text input features single- and input, sets, free caret movement, copying and pasting, and more.
+ /// To use this class, , and have to be called regularly.
+ /// While this class is used by MLEM.Ui's TextField, it is designed to be used for custom or external UI systems.
+ ///
+ public class TextInput {
+
+ ///
+ /// A that allows any visible character and spaces
+ ///
+ public static readonly Rule DefaultRule = (input, add) => {
+ foreach (var c in add) {
+ if (char.IsControl(c) && (!input.Multiline || c != '\n'))
+ return false;
+ }
+ return true;
+ };
+ ///
+ /// A that only allows letters
+ ///
+ public static readonly Rule OnlyLetters = (input, add) => {
+ foreach (var c in add) {
+ if (!char.IsLetter(c))
+ return false;
+ }
+ return true;
+ };
+ ///
+ /// A that only allows numerals
+ ///
+ public static readonly Rule OnlyNumbers = (input, add) => {
+ foreach (var c in add) {
+ if (!char.IsNumber(c))
+ return false;
+ }
+ return true;
+ };
+ ///
+ /// A that only allows letters and numerals
+ ///
+ public static readonly Rule LettersNumbers = (input, add) => {
+ foreach (var c in add) {
+ if (!char.IsLetter(c) || !char.IsNumber(c))
+ return false;
+ }
+ return true;
+ };
+ ///
+ /// A that only allows characters not contained in
+ ///
+ public static readonly Rule PathNames = (input, add) => add.IndexOfAny(Path.GetInvalidPathChars()) < 0;
+ ///
+ /// A that only allows characters not contained in
+ ///
+ public static readonly Rule FileNames = (input, add) => add.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
+
+ ///
+ /// This text input's current text
+ ///
+ public string Text => this.text.ToString();
+ ///
+ /// The length that this 's text has.
+ ///
+ public int Length => this.text.Length;
+ ///
+ /// An event that gets called when changes, either through input, or through a manual change.
+ ///
+ public TextChanged OnTextChange;
+ ///
+ /// The rule used for text input.
+ /// Rules allow only certain characters to be allowed inside of a text input.
+ ///
+ public Rule InputRule;
+ ///
+ /// The position of the caret within the text.
+ /// This is always between 0 and the of
+ ///
+ public int CaretPos {
+ get => this.caretPos;
+ set {
+ var val = MathHelper.Clamp(value, 0, this.text.Length);
+ if (this.caretPos != val) {
+ this.caretPos = val;
+ this.caretBlinkTimer = 0;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// The line of text that the caret is currently on.
+ /// This can only be only non-0 if is true.
+ ///
+ public int CaretLine { get; private set; }
+ ///
+ /// The position in the current that the caret is currently on.
+ /// If is false, this value is always equal to .
+ ///
+ public int CaretPosInLine { get; private set; }
+ ///
+ /// A character that should be displayed instead of this text input's content.
+ /// The amount of masking characters displayed will be equal to the 's length.
+ /// This behavior is useful for password inputs or similar.
+ ///
+ public char? MaskingCharacter {
+ get => this.maskingCharacter;
+ set {
+ if (this.maskingCharacter != value) {
+ this.maskingCharacter = value;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// The maximum amount of characters that can be input into this text input.
+ /// If this is set, the length of will never exceed this value.
+ ///
+ public int? MaximumCharacters;
+ ///
+ /// Whether this text input should support multi-line editing.
+ /// If this is true, pressing will insert a new line into the if the allows it.
+ /// Additionally, text will be rendered with horizontal soft wraps, and lines that are outside of the text input's bounds will be hidden.
+ ///
+ public bool Multiline {
+ get => this.multiline;
+ set {
+ if (this.multiline != value) {
+ this.multiline = value;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// The font that this text input is currently using.
+ ///
+ public GenericFont Font {
+ get => this.font;
+ set {
+ if (this.font != value) {
+ this.font = value;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// The scale that this text input's text has.
+ /// In , this is multiplied with the drawScale parameter.
+ ///
+ public float TextScale {
+ get => this.textScale;
+ set {
+ if (this.textScale != value) {
+ this.textScale = value;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// The size of this text input, which is the size that this text input's will be bounded to in .
+ /// Note that gets applied to the text for calculations involving this size.
+ ///
+ public Vector2 Size {
+ get => this.size;
+ set {
+ if (this.size != value) {
+ this.size = value;
+ this.UpdateTextData(false);
+ }
+ }
+ }
+ ///
+ /// A function that is invoked when a string of text should be copied to the clipboard.
+ /// MLEM.Ui uses the TextCopy package for this, but other options are available.
+ ///
+ public Action CopyToClipboardFunction;
+ ///
+ /// A function that is invoked when a string of text should be pasted from the clipboard.
+ /// MLEM.Ui uses the TextCopy package for this, but other options are available.
+ ///
+ public Func PasteFromClipboardFunction;
+
+ private readonly StringBuilder text = new StringBuilder();
+
+ private char? maskingCharacter;
+ private double caretBlinkTimer;
+ private string displayedText;
+ private string[] splitText;
+ private int textOffset;
+ private int lineOffset;
+ private int caretPos;
+ private float caretDrawOffset;
+ private bool multiline;
+ private GenericFont font;
+ private float textScale;
+ private Vector2 size;
+
+ ///
+ /// Creates a new text input with the given settings.
+ ///
+ /// The to use.
+ /// The to set.
+ /// The to set.
+ /// The to set.
+ /// The to set.
+ /// The to set.
+ public TextInput(GenericFont font, Vector2 size, float textScale, Rule inputRule = null, Action copyToClipboardFunction = null, Func pasteFromClipboardFunction = null) {
+ this.InputRule = inputRule ?? TextInput.DefaultRule;
+ this.CopyToClipboardFunction = copyToClipboardFunction;
+ this.PasteFromClipboardFunction = pasteFromClipboardFunction;
+ this.Font = font;
+ this.Size = size;
+ this.TextScale = textScale;
+ }
+
+ ///
+ /// A method that should be called when the given text should be entered into this text input.
+ /// This method is designed to be used with the event.
+ ///
+ /// The key that was pressed.
+ /// The character that the represents.
+ /// Whether text was successfully input.
+ public bool OnTextInput(Keys key, char character) {
+ if (key == Keys.Back) {
+ if (this.CaretPos > 0) {
+ this.CaretPos--;
+ this.RemoveText(this.CaretPos, 1);
+ return true;
+ }
+ } else if (key == Keys.Delete) {
+ return this.RemoveText(this.CaretPos, 1);
+ } else if (this.Multiline && key == Keys.Enter) {
+ return this.InsertText('\n');
+ } else {
+ return this.InsertText(character);
+ }
+ return false;
+ }
+
+ ///
+ /// Updates this text input, including querying input using the given and updating the caret's blink timer.
+ ///
+ /// The current game time.
+ /// The input handler to use for input querying.
+ public void Update(GameTime time, InputHandler input) {
+ if (this.CaretPos > 0 && input.TryConsumePressed(Keys.Left)) {
+ this.CaretPos--;
+ } else if (this.CaretPos < this.text.Length && input.TryConsumePressed(Keys.Right)) {
+ this.CaretPos++;
+ } else if (this.Multiline && input.IsKeyPressedAvailable(Keys.Up) && this.MoveCaretToLine(this.CaretLine - 1)) {
+ input.TryConsumeKeyPressed(Keys.Up);
+ } else if (this.Multiline && input.IsKeyPressedAvailable(Keys.Down) && this.MoveCaretToLine(this.CaretLine + 1)) {
+ input.TryConsumeKeyPressed(Keys.Down);
+ } else if (this.CaretPos != 0 && input.TryConsumeKeyPressed(Keys.Home)) {
+ this.CaretPos = 0;
+ } else if (this.CaretPos != this.text.Length && input.TryConsumeKeyPressed(Keys.End)) {
+ this.CaretPos = this.text.Length;
+ } else if (input.IsModifierKeyDown(ModifierKey.Control)) {
+ if (input.IsKeyPressedAvailable(Keys.V)) {
+ var clip = this.PasteFromClipboardFunction?.Invoke();
+ if (clip != null) {
+ this.InsertText(clip, true);
+ input.TryConsumeKeyPressed(Keys.V);
+ }
+ } else if (input.TryConsumeKeyPressed(Keys.C)) {
+ // until there is text selection, just copy the whole content
+ this.CopyToClipboardFunction?.Invoke(this.Text);
+ }
+ }
+
+ this.caretBlinkTimer += time.ElapsedGameTime.TotalSeconds;
+ if (this.caretBlinkTimer >= 1)
+ this.caretBlinkTimer = 0;
+ }
+
+ ///
+ /// Draws this text input's displayed along with its caret, if is greater than 0.
+ ///
+ /// The sprite batch to draw with.
+ /// The position to draw the text at.
+ /// The draw scale, which is multiplied with before drawing.
+ /// The width that the caret should have, which is multiplied with before drawing.
+ /// The color to draw the text and caret with.
+ public void Draw(SpriteBatch batch, Vector2 textPos, float drawScale, float caretWidth, Color textColor) {
+ // handle first initialization if not done
+ if (this.displayedText == null)
+ this.UpdateTextData(false);
+
+ var scale = this.TextScale * drawScale;
+ this.Font.DrawString(batch, this.displayedText, textPos, textColor, 0, Vector2.Zero, scale, SpriteEffects.None, 0);
+
+ if (caretWidth > 0 && this.caretBlinkTimer < 0.5F) {
+ var caretDrawPos = textPos + new Vector2(this.caretDrawOffset * scale, 0);
+ if (this.Multiline)
+ caretDrawPos.Y += this.Font.LineHeight * (this.CaretLine - this.lineOffset) * scale;
+ batch.Draw(batch.GetBlankTexture(), new RectangleF(caretDrawPos, new Vector2(caretWidth * drawScale, this.Font.LineHeight * scale)), null, textColor);
+ }
+ }
+
+ ///
+ /// Replaces this text input's text with the given text.
+ /// If the resulting exceeds , the end will be cropped to fit.
+ ///
+ /// The new text
+ /// If any characters that don't match the should be left out
+ public void SetText(object text, bool removeMismatching = false) {
+ var strg = text?.ToString() ?? string.Empty;
+ if (!this.FilterText(ref strg, removeMismatching))
+ return;
+ if (this.MaximumCharacters != null && strg.Length > this.MaximumCharacters)
+ strg = strg.Substring(0, this.MaximumCharacters.Value);
+ this.text.Clear();
+ this.text.Append(strg);
+ this.CaretPos = this.text.Length;
+ this.UpdateTextData();
+ }
+
+ ///
+ /// Inserts the given text at the .
+ /// If the resulting exceeds , the end will be cropped to fit.
+ ///
+ /// The text to insert
+ /// If any characters that don't match the should be left out
+ public bool InsertText(object text, bool removeMismatching = false) {
+ var strg = text?.ToString() ?? string.Empty;
+ if (!this.FilterText(ref strg, removeMismatching))
+ return false;
+ if (this.MaximumCharacters != null && this.text.Length + strg.Length > this.MaximumCharacters)
+ strg = strg.Substring(0, this.MaximumCharacters.Value - this.text.Length);
+ this.text.Insert(this.CaretPos, strg);
+ this.CaretPos += strg.Length;
+ this.UpdateTextData();
+ return true;
+ }
+
+ ///
+ /// Removes the given amount of text at the given index
+ ///
+ /// The index
+ /// The amount of text to remove
+ public bool RemoveText(int index, int length) {
+ if (index < 0 || index >= this.text.Length)
+ return false;
+ this.text.Remove(index, length);
+ // ensure that caret pos is still in bounds
+ this.CaretPos = this.CaretPos;
+ this.UpdateTextData();
+ return true;
+ }
+
+ ///
+ /// Moves the to the given line, if it exists.
+ /// Additionally maintains the roughly based on the visual distance that the caret has from the left border of the current .
+ ///
+ /// The line to move the caret to
+ /// True if the caret was moved, false if it was not (which indicates that the line with the given index does not exist)
+ public bool MoveCaretToLine(int line) {
+ var (destStart, destEnd) = this.GetLineBounds(line);
+ if (destEnd > 0) {
+ // find the position whose distance from the start is closest to the current distance from the start
+ var destAccum = "";
+ while (destAccum.Length < destEnd - destStart) {
+ if (this.Font.MeasureString(destAccum).X >= this.caretDrawOffset) {
+ this.CaretPos = destStart + destAccum.Length;
+ return true;
+ }
+ destAccum += this.text[destStart + destAccum.Length];
+ }
+ // if we don't find a proper position, just move to the end of the destination line
+ this.CaretPos = destEnd;
+ return true;
+ }
+ return false;
+ }
+
+ private bool FilterText(ref string text, bool removeMismatching) {
+ if (removeMismatching) {
+ var result = new StringBuilder();
+ foreach (var c in text) {
+ if (this.InputRule(this, c.ToCachedString()))
+ result.Append(c);
+ }
+ text = result.ToString();
+ } else if (!this.InputRule(this, text))
+ return false;
+ return true;
+ }
+
+ private void UpdateTextData(bool textChanged = true) {
+ if (this.Font == null)
+ return;
+ if (this.Multiline) {
+ // soft wrap if we're multiline
+ this.splitText = this.Font.SplitStringSeparate(this.text, this.Size.X, this.TextScale).ToArray();
+ this.displayedText = string.Join("\n", this.splitText);
+ this.UpdateCaretData();
+
+ if (this.Font.MeasureString(this.displayedText).Y * this.TextScale > this.Size.Y) {
+ var maxLines = (this.Size.Y / (this.Font.LineHeight * this.TextScale)).Floor();
+ if (this.lineOffset > this.CaretLine) {
+ // if we're moving up
+ this.lineOffset = this.CaretLine;
+ } else if (this.CaretLine >= maxLines) {
+ // if we're moving down
+ var limit = this.CaretLine - (maxLines - 1);
+ if (limit > this.lineOffset)
+ this.lineOffset = limit;
+ }
+ // calculate resulting string
+ var ret = new StringBuilder();
+ var lines = 0;
+ var originalIndex = 0;
+ for (var i = 0; i < this.displayedText.Length; i++) {
+ if (lines >= this.lineOffset) {
+ if (ret.Length <= 0)
+ this.textOffset = originalIndex;
+ ret.Append(this.displayedText[i]);
+ }
+ if (this.displayedText[i] == '\n') {
+ lines++;
+ if (this.text[originalIndex] == '\n')
+ originalIndex++;
+ } else {
+ originalIndex++;
+ }
+ if (lines - this.lineOffset >= maxLines)
+ break;
+ }
+ this.displayedText = ret.ToString();
+ } else {
+ this.lineOffset = 0;
+ this.textOffset = 0;
+ }
+ } else {
+ // not multiline, so scroll horizontally based on caret position
+ if (this.Font.MeasureString(this.text).X * this.TextScale > this.Size.X) {
+ if (this.textOffset > this.CaretPos) {
+ // if we're moving the caret to the left
+ this.textOffset = this.CaretPos;
+ } else {
+ // if we're moving the caret to the right
+ var importantArea = this.text.ToString(this.textOffset, Math.Min(this.CaretPos, this.text.Length) - this.textOffset);
+ var bound = this.CaretPos - this.Font.TruncateString(importantArea, this.Size.X, this.TextScale, true).Length;
+ if (this.textOffset < bound)
+ this.textOffset = bound;
+ }
+ var visible = this.text.ToString(this.textOffset, this.text.Length - this.textOffset);
+ this.displayedText = this.Font.TruncateString(visible, this.Size.X, this.TextScale);
+ } else {
+ this.displayedText = this.Text;
+ this.textOffset = 0;
+ }
+ this.UpdateCaretData();
+ }
+
+ if (this.MaskingCharacter != null)
+ this.displayedText = new string(this.MaskingCharacter.Value, this.displayedText.Length);
+
+ if (textChanged)
+ this.OnTextChange?.Invoke(this, this.Text);
+ }
+
+ private void UpdateCaretData() {
+ if (this.splitText != null) {
+ var line = 0;
+ var index = 0;
+ for (var d = 0; d < this.splitText.Length; d++) {
+ var startOfLine = 0;
+ var split = this.splitText[d];
+ for (var i = 0; i <= split.Length; i++) {
+ if (index == this.CaretPos) {
+ this.CaretLine = line;
+ this.CaretPosInLine = i - startOfLine;
+ this.caretDrawOffset = this.Font.MeasureString(split.Substring(startOfLine, this.CaretPosInLine)).X;
+ return;
+ }
+ if (i < split.Length) {
+ // manual splits
+ if (split[i] == '\n') {
+ startOfLine = i + 1;
+ line++;
+ }
+ index++;
+ }
+ }
+ // max width splits
+ line++;
+ }
+ } else if (this.displayedText != null) {
+ this.CaretLine = 0;
+ this.CaretPosInLine = this.CaretPos;
+ this.caretDrawOffset = this.Font.MeasureString(this.displayedText.Substring(0, this.CaretPos - this.textOffset)).X;
+ }
+ }
+
+ private (int, int) GetLineBounds(int boundLine) {
+ if (this.splitText != null) {
+ var line = 0;
+ var index = 0;
+ var startOfLineIndex = 0;
+ for (var d = 0; d < this.splitText.Length; d++) {
+ var split = this.splitText[d];
+ for (var i = 0; i < split.Length; i++) {
+ index++;
+ if (split[i] == '\n') {
+ if (boundLine == line)
+ return (startOfLineIndex, index - 1);
+ line++;
+ startOfLineIndex = index;
+ }
+ }
+ if (boundLine == line)
+ return (startOfLineIndex, index - 1);
+ line++;
+ startOfLineIndex = index;
+ }
+ }
+ return default;
+ }
+
+ ///
+ /// A delegate method used for
+ ///
+ /// The text input whose text changed
+ /// The new text
+ public delegate void TextChanged(TextInput input, string text);
+
+ ///
+ /// A delegate method used for .
+ /// It should return whether the given text can be added to the text input.
+ ///
+ /// The text input
+ /// The text that is tried to be added
+ public delegate bool Rule(TextInput input, string textToAdd);
+
+ }
+}