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) => { foreach (var c in add) { if (char.IsControl(c) || Path.GetInvalidPathChars().Contains(c)) return false; } return true; }; /// /// A that only allows characters not contained in /// public static readonly Rule FileNames = (input, add) => { foreach (var c in add) { if (char.IsControl(c) || Path.GetInvalidFileNameChars().Contains(c)) return false; } return true; }; /// /// 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 = (int) MathHelper.Clamp(value, 0F, this.text.Length); if (this.caretPos != val) { // ensure that we don't move to a location that is between high and low surrogates this.caretPos = new CodePointSource(this.text).EnsureSurrogateBoundary(val, val > this.caretPos); this.caretBlinkTimer = 0; this.SetTextDataDirty(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 { this.UpdateTextDataIfDirty(); return this.caretLine; } } /// /// The position in the current that the caret is currently on. /// If is false, this value is always equal to . /// public int CaretPosInLine { get { this.UpdateTextDataIfDirty(); return this.caretPosInLine; } } /// /// 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.SetTextDataDirty(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.SetTextDataDirty(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.SetTextDataDirty(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.SetTextDataDirty(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.SetTextDataDirty(false); } } } /// /// The maximum amount of lines that can be visible in this text input, based on its , the used and its . /// Note that this may return a number higher than 1 even if this is not a text input. /// public int MaxDisplayedLines => (this.Size.Y / (this.Font.LineHeight * this.TextScale)).Floor(); /// /// The index of the first line that is currently visible. /// This value can be changed using . /// public int FirstVisibleLine { get; private set; } /// /// The total amount of lines of text that this text input currently has, including additional lines added by automatic wrapping. /// If this is not a text input, this value is always 1. /// public int Lines { get; private set; } /// /// 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 visibleText; private string[] multilineSplitText; private int textOffset; private int caretPos; private int caretLine; private int caretPosInLine; private float caretDrawOffset; private bool multiline; private GenericFont font; private float textScale; private Vector2 size; private bool textDataDirty; /// /// 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 or the TextInput event provided by MonoGame and FNA. /// /// The key that was pressed. /// The character that the represents. /// Whether text was successfully input. public bool OnTextInput(Keys key, char character) { // FNA's text input event doesn't supply keys, so we handle this in Update #if !FNA 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; #else return this.InsertText(character); #endif } /// /// 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) { this.UpdateTextDataIfDirty(); #if FNA // FNA's text input event doesn't supply keys, so we handle this here if (this.CaretPos > 0 && input.TryConsumePressed(Keys.Back)) { this.CaretPos--; this.RemoveText(this.CaretPos, 1); } else if (this.CaretPos < this.text.Length && input.TryConsumePressed(Keys.Delete)) { this.RemoveText(this.CaretPos, 1); } else if (this.Multiline && input.TryConsumePressed(Keys.Enter)) { this.InsertText('\n'); } else #endif 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.IsPressedAvailable(Keys.Up) && (input.IsModifierKeyDown(ModifierKey.Control) ? this.ShowLine(this.FirstVisibleLine - 1) : this.MoveCaretToLine(this.CaretLine - 1))) { input.TryConsumePressed(Keys.Up); } else if (this.Multiline && input.IsPressedAvailable(Keys.Down) && (input.IsModifierKeyDown(ModifierKey.Control) ? this.ShowLine(this.FirstVisibleLine + 1) : this.MoveCaretToLine(this.CaretLine + 1))) { input.TryConsumePressed(Keys.Down); } else if (this.CaretPos != 0 && input.TryConsumePressed(Keys.Home)) { this.CaretPos = 0; } else if (this.CaretPos != this.text.Length && input.TryConsumePressed(Keys.End)) { this.CaretPos = this.text.Length; } else if (input.IsModifierKeyDown(ModifierKey.Control)) { if (input.IsPressedAvailable(Keys.V)) { var clip = this.PasteFromClipboardFunction?.Invoke(); if (clip != null) { this.InsertText(clip, true); input.TryConsumePressed(Keys.V); } } else if (input.TryConsumePressed(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) { this.UpdateTextDataIfDirty(); var scale = this.TextScale * drawScale; this.Font.DrawString(batch, this.visibleText, 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.FirstVisibleLine) * 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, new CodePointSource(strg).EnsureSurrogateBoundary(this.MaximumCharacters.Value, false)); this.text.Clear(); this.text.Append(strg); this.CaretPos = this.text.Length; this.SetTextDataDirty(); } /// /// 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, new CodePointSource(strg).EnsureSurrogateBoundary(this.MaximumCharacters.Value - this.text.Length, false)); this.text.Insert(this.CaretPos, strg); this.CaretPos += strg.Length; this.SetTextDataDirty(); 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; var source = new CodePointSource(this.text); this.text.Remove(source.EnsureSurrogateBoundary(index, false), source.EnsureSurrogateBoundary(index + length, true) - index); // ensure that caret pos is still in bounds this.CaretPos = this.CaretPos; this.SetTextDataDirty(); 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) { this.UpdateTextDataIfDirty(); 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 += CodePointSource.ToString(new CodePointSource(this.text).GetCodePoint(destStart + destAccum.Length).CodePoint); } // if we don't find a proper position, just move to the end of the destination line this.CaretPos = destEnd; return true; } return false; } /// /// Moves visual focus into such bounds that the given line will be the first visible line of this text input. /// /// The first line that should be visible. /// Whether the line can be the fist visible line, and wasn't already the first visible line. public bool ShowLine(int line) { if (this.FirstVisibleLine != line && line >= 0 && line < this.Lines - (this.MaxDisplayedLines - 1)) { this.FirstVisibleLine = line; // move the caret into visible bounds if necessary var clampedCaretLine = (int) MathHelper.Clamp(this.CaretLine, line, line + this.MaxDisplayedLines - 1F); if (clampedCaretLine != this.CaretLine) this.MoveCaretToLine(clampedCaretLine); this.SetTextDataDirty(false); return true; } return false; } private bool FilterText(ref string text, bool removeMismatching) { var result = new StringBuilder(); foreach (var codePoint in new CodePointSource(text)) { var character = char.ConvertFromUtf32(codePoint); if (this.InputRule(this, character)) { result.Append(character); } else if (!removeMismatching) { // if we don't remove mismatching characters, we just fail return false; } } text = result.ToString(); return true; } private void SetTextDataDirty(bool textChanged = true) { this.textDataDirty = true; if (textChanged) this.OnTextChange?.Invoke(this, this.Text); } private void UpdateTextDataIfDirty() { if (!this.textDataDirty || this.Font == null) return; this.textDataDirty = false; var visualText = this.text; if (this.MaskingCharacter != null) visualText = new StringBuilder(visualText.Length).Append(this.MaskingCharacter.Value, visualText.Length); if (this.Multiline) { // soft wrap if we're multiline this.multilineSplitText = this.Font.SplitStringSeparate(visualText, this.Size.X, this.TextScale).ToArray(); this.visibleText = string.Join("\n", this.multilineSplitText); this.Lines = this.visibleText.Count(c => c == '\n') + 1; this.UpdateCaretData(); if (this.Font.MeasureString(this.visibleText).Y * this.TextScale > this.Size.Y) { if (this.FirstVisibleLine > this.CaretLine) { // if we're moving up this.FirstVisibleLine = this.CaretLine; } else if (this.CaretLine >= this.MaxDisplayedLines) { // if we're moving down var limit = this.CaretLine - (this.MaxDisplayedLines - 1); if (limit > this.FirstVisibleLine) this.FirstVisibleLine = limit; } // calculate resulting string var ret = new StringBuilder(); var lines = 0; var originalIndex = 0; for (var i = 0; i < this.visibleText.Length; i++) { if (lines >= this.FirstVisibleLine) { if (ret.Length <= 0) this.textOffset = originalIndex; ret.Append(this.visibleText[i]); } if (this.visibleText[i] == '\n') { lines++; if (visualText[originalIndex] == '\n') originalIndex++; } else { originalIndex++; } if (lines - this.FirstVisibleLine >= this.MaxDisplayedLines) break; } this.visibleText = ret.ToString(); } else { this.FirstVisibleLine = 0; this.textOffset = 0; } } else { this.multilineSplitText = null; this.FirstVisibleLine = 0; this.Lines = 1; // not multiline, so scroll horizontally based on caret position if (this.Font.MeasureString(visualText).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 = visualText.ToString(this.textOffset, Math.Min(this.CaretPos, visualText.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 = visualText.ToString(this.textOffset, visualText.Length - this.textOffset); this.visibleText = this.Font.TruncateString(visible, this.Size.X, this.TextScale); } else { this.visibleText = visualText.ToString(); this.textOffset = 0; } this.UpdateCaretData(); } } private void UpdateCaretData() { if (this.multilineSplitText != null) { // the code below will never execute if our text is empty, so reset our caret position fully if (this.multilineSplitText.Length <= 0) { this.caretLine = 0; this.caretPosInLine = 0; this.caretDrawOffset = 0; return; } var line = 0; var index = 0; for (var d = 0; d < this.multilineSplitText.Length; d++) { var startOfLine = 0; var split = this.multilineSplitText[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.visibleText != null) { this.caretLine = 0; this.caretPosInLine = this.CaretPos; this.caretDrawOffset = this.Font.MeasureString(this.visibleText.Substring(0, this.CaretPos - this.textOffset)).X; } } private (int, int) GetLineBounds(int boundLine) { if (this.multilineSplitText != null) { var line = 0; var index = 0; var startOfLineIndex = 0; for (var d = 0; d < this.multilineSplitText.Length; d++) { var split = this.multilineSplitText[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); } }