From aff61508c45c1c0391a07a817722702e6271a733 Mon Sep 17 00:00:00 2001 From: Ellpeck Date: Sun, 19 Jun 2022 18:17:46 +0200 Subject: [PATCH] Added TextInput class, which is an isolated version of MLEM.Ui's TextField logic --- CHANGELOG.md | 3 +- MLEM.Ui/Elements/TextField.cs | 528 +++++++------------------------- MLEM/Input/TextInput.cs | 548 ++++++++++++++++++++++++++++++++++ 3 files changed, 651 insertions(+), 428 deletions(-) create mode 100644 MLEM/Input/TextInput.cs 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); + + } +}