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.Input; using MLEM.Misc; using MLEM.Textures; using MLEM.Ui.Style; using TextCopy; 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 . /// 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; /// /// The color that this text field's text should display with /// public StyleProp TextColor; /// /// The color that the should display with /// public StyleProp PlaceholderColor; /// /// This text field's texture /// public StyleProp Texture; /// /// This text field's texture while it is hovered /// public StyleProp HoveredTexture; /// /// The color that this text field should display with while it is hovered /// public StyleProp HoveredColor; /// /// The scale that this text field should render text with /// public StyleProp TextScale; /// /// 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; /// /// The x position that text should start rendering at, based on the x position of this text field. /// public float TextOffsetX = 4; /// /// The width that the caret should render with. /// public float CaretWidth = 0.5F; /// /// The rule used for text input. /// Rules allow only certain characters to be allowed inside of a text field. /// public Rule InputRule; /// /// The title of the KeyboardInput field on mobile devices and consoles /// public string MobileTitle; /// /// 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; /// /// Creates a new text field with the given settings /// /// The text field's anchor /// The text field's size /// The text field's input rule /// The font to use for drawing text /// 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.InputRule = rule ?? DefaultRule; this.Multiline = multiline; if (font != null) this.Font.Set(font); if (text != null) this.SetText(text, true); 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) { 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); } } /// 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.Input.IsKeyPressed(Keys.Left)) { this.CaretPos--; } else if (this.Input.IsKeyPressed(Keys.Right)) { this.CaretPos++; } else if (this.Multiline && this.Input.IsKeyPressed(Keys.Up)) { this.MoveCaretToLine(this.CaretLine - 1); } else if (this.Multiline && this.Input.IsKeyPressed(Keys.Down)) { this.MoveCaretToLine(this.CaretLine + 1); } else if (this.Input.IsKeyPressed(Keys.Home)) { this.CaretPos = 0; } else if (this.Input.IsKeyPressed(Keys.End)) { this.CaretPos = this.text.Length; } else if (this.Input.IsModifierKeyDown(ModifierKey.Control)) { if (this.Input.IsKeyPressed(Keys.V)) { var clip = ClipboardService.GetText(); if (clip != null) this.InsertText(clip, true); } else if (this.Input.IsKeyPressed(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; } /// public override void Draw(GameTime time, SpriteBatch batch, float alpha, BlendState blendState, SamplerState samplerState, Matrix matrix) { var tex = this.Texture; var color = Color.White * alpha; if (this.IsMouseOver) { tex = this.HoveredTexture.OrDefault(tex); color = (Color) this.HoveredColor * alpha; } 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); } } base.Draw(time, batch, alpha, blendState, samplerState, matrix); } /// /// 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(); } /// /// 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(); } /// /// 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; } /// protected override void InitStyle(UiStyle style) { base.InitStyle(style); this.TextScale.SetFromStyle(style.TextScale); this.Font.SetFromStyle(style.Font); this.Texture.SetFromStyle(style.TextFieldTexture); this.HoveredTexture.SetFromStyle(style.TextFieldHoveredTexture); this.HoveredColor.SetFromStyle(style.TextFieldHoveredColor); } 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.ToString(), 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.ToString()).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 /// /// The text field whose text changed /// The new text public delegate void TextChanged(TextField field, string text); /// /// A delegate method used for . /// It should return whether the given text can be added to the text field. /// /// The text field /// The text that is tried to be added public delegate bool Rule(TextField field, string textToAdd); } }