From 764b29e1208086b3872294c510776e7276934368 Mon Sep 17 00:00:00 2001 From: Ellpeck Date: Sat, 2 Dec 2023 19:28:59 +0100 Subject: [PATCH] Text input improvements: - Allow using control and arrow keys to move the visible area of a text input - Don't reset the caret position of a text field when selecting or deselecting it --- CHANGELOG.md | 2 + MLEM.Ui/Elements/TextField.cs | 2 - MLEM/Input/TextInput.cs | 109 ++++++++++++++++++++++------------ 3 files changed, 74 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f1d6e..e9c170f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Additions Improvements - Stopped the text formatter throwing if a color can't be parsed - Improved text formatter tokenization performance +- Allow using control and arrow keys to move the visible area of a text input Fixes - Fixed TextInput not working correctly when using surrogate pairs @@ -37,6 +38,7 @@ Improvements - Allow scrolling panels to contain other scrolling panels - Allow dropdowns to have scrolling panels - Improved Panel performance when adding and removing a lot of children +- Don't reset the caret position of a text field when selecting or deselecting it Fixes - Fixed panels updating their relevant children too much when the scroll bar is hidden diff --git a/MLEM.Ui/Elements/TextField.cs b/MLEM.Ui/Elements/TextField.cs index e7cbb46..998e3cd 100644 --- a/MLEM.Ui/Elements/TextField.cs +++ b/MLEM.Ui/Elements/TextField.cs @@ -204,8 +204,6 @@ namespace MLEM.Ui.Elements { if (this.IsSelectedActive && !this.IsHidden && !this.textInput.OnTextInput(key, character) && key == Keys.Enter && !this.Multiline) this.EnterReceiver?.Controls?.PressElement(this.EnterReceiver); }; - this.OnDeselected += e => this.CaretPos = 0; - this.OnSelected += e => this.CaretPos = this.textInput.Length; } /// diff --git a/MLEM/Input/TextInput.cs b/MLEM/Input/TextInput.cs index 45ce6fb..3a45970 100644 --- a/MLEM/Input/TextInput.cs +++ b/MLEM/Input/TextInput.cs @@ -204,6 +204,21 @@ namespace MLEM.Input { } } /// + /// 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. /// @@ -218,10 +233,9 @@ namespace MLEM.Input { private char? maskingCharacter; private double caretBlinkTimer; - private string displayedText; - private string[] splitText; + private string visibleText; + private string[] multilineSplitText; private int textOffset; - private int lineOffset; private int caretPos; private int caretLine; private int caretPosInLine; @@ -302,9 +316,9 @@ namespace MLEM.Input { this.CaretPos--; } else if (this.CaretPos < this.text.Length && input.TryConsumePressed(Keys.Right)) { this.CaretPos++; - } else if (this.Multiline && input.IsPressedAvailable(Keys.Up) && this.MoveCaretToLine(this.CaretLine - 1)) { + } 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) && this.MoveCaretToLine(this.CaretLine + 1)) { + } 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; @@ -340,12 +354,12 @@ namespace MLEM.Input { this.UpdateTextDataIfDirty(); var scale = this.TextScale * drawScale; - this.Font.DrawString(batch, this.displayedText, textPos, textColor, 0, Vector2.Zero, scale, SpriteEffects.None, 0); + 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.lineOffset) * scale; + 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); } } @@ -428,6 +442,26 @@ namespace MLEM.Input { 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)) { @@ -460,49 +494,50 @@ namespace MLEM.Input { if (this.Multiline) { // soft wrap if we're multiline - this.splitText = this.Font.SplitStringSeparate(visualText, this.Size.X, this.TextScale).ToArray(); - this.displayedText = string.Join("\n", this.splitText); + 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.displayedText).Y * this.TextScale > this.Size.Y) { - var maxLines = (this.Size.Y / (this.Font.LineHeight * this.TextScale)).Floor(); - if (this.lineOffset > this.CaretLine) { + if (this.Font.MeasureString(this.visibleText).Y * this.TextScale > this.Size.Y) { + if (this.FirstVisibleLine > this.CaretLine) { // if we're moving up - this.lineOffset = this.CaretLine; - } else if (this.CaretLine >= maxLines) { + this.FirstVisibleLine = this.CaretLine; + } else if (this.CaretLine >= this.MaxDisplayedLines) { // if we're moving down - var limit = this.CaretLine - (maxLines - 1); - if (limit > this.lineOffset) - this.lineOffset = limit; + 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.displayedText.Length; i++) { - if (lines >= this.lineOffset) { + for (var i = 0; i < this.visibleText.Length; i++) { + if (lines >= this.FirstVisibleLine) { if (ret.Length <= 0) this.textOffset = originalIndex; - ret.Append(this.displayedText[i]); + ret.Append(this.visibleText[i]); } - if (this.displayedText[i] == '\n') { + if (this.visibleText[i] == '\n') { lines++; if (visualText[originalIndex] == '\n') originalIndex++; } else { originalIndex++; } - if (lines - this.lineOffset >= maxLines) + if (lines - this.FirstVisibleLine >= this.MaxDisplayedLines) break; } - this.displayedText = ret.ToString(); + this.visibleText = ret.ToString(); } else { - this.lineOffset = 0; + this.FirstVisibleLine = 0; this.textOffset = 0; } } else { - this.splitText = null; - this.lineOffset = 0; + 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) { @@ -516,9 +551,9 @@ namespace MLEM.Input { this.textOffset = bound; } var visible = visualText.ToString(this.textOffset, visualText.Length - this.textOffset); - this.displayedText = this.Font.TruncateString(visible, this.Size.X, this.TextScale); + this.visibleText = this.Font.TruncateString(visible, this.Size.X, this.TextScale); } else { - this.displayedText = visualText.ToString(); + this.visibleText = visualText.ToString(); this.textOffset = 0; } this.UpdateCaretData(); @@ -526,9 +561,9 @@ namespace MLEM.Input { } private void UpdateCaretData() { - if (this.splitText != null) { + if (this.multilineSplitText != null) { // the code below will never execute if our text is empty, so reset our caret position fully - if (this.splitText.Length <= 0) { + if (this.multilineSplitText.Length <= 0) { this.caretLine = 0; this.caretPosInLine = 0; this.caretDrawOffset = 0; @@ -537,9 +572,9 @@ namespace MLEM.Input { var line = 0; var index = 0; - for (var d = 0; d < this.splitText.Length; d++) { + for (var d = 0; d < this.multilineSplitText.Length; d++) { var startOfLine = 0; - var split = this.splitText[d]; + var split = this.multilineSplitText[d]; for (var i = 0; i <= split.Length; i++) { if (index == this.CaretPos) { this.caretLine = line; @@ -559,20 +594,20 @@ namespace MLEM.Input { // max width splits line++; } - } else if (this.displayedText != null) { + } else if (this.visibleText != null) { this.caretLine = 0; this.caretPosInLine = this.CaretPos; - this.caretDrawOffset = this.Font.MeasureString(this.displayedText.Substring(0, this.CaretPos - this.textOffset)).X; + this.caretDrawOffset = this.Font.MeasureString(this.visibleText.Substring(0, this.CaretPos - this.textOffset)).X; } } private (int, int) GetLineBounds(int boundLine) { - if (this.splitText != null) { + if (this.multilineSplitText != 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 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') {