1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-11-24 13:38:34 +01:00

Compare commits

..

4 commits

6 changed files with 185 additions and 50 deletions

View file

@ -10,8 +10,13 @@ Jump to version:
### MLEM
Additions
- Added a strikethrough formatting code
- Added GenericFont SplitStringSeparate which differentiates between existing newline characters and splits due to maximum width
### MLEM.Ui
Additions
- Allow specifying a maximum amount of characters for a TextField
- Added a multiline editing mode to TextField
Improvements
- Cache TokenizedString inner offsets for non-Left text alignments to improve performance

View file

@ -95,8 +95,8 @@ namespace Demos {
this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "Defining text animations as formatting codes is also possible, including <a wobbly>wobbly text</a> at <a wobbly 8 0.25>different intensities</a>. Of course, more animations can be added though."));
this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Paragraph(Anchor.AutoCenter, 1, "Text input:", true));
this.root.AddChild(new TextField(Anchor.AutoLeft, new Vector2(1, 10)) {
this.root.AddChild(new Paragraph(Anchor.AutoCenter, 1, "Multiline text input:", true));
this.root.AddChild(new TextField(Anchor.AutoLeft, new Vector2(1, 50), multiline: true) {
PositionOffset = new Vector2(0, 1),
PlaceholderText = "Click here to input text"
});

View file

@ -25,7 +25,7 @@ namespace MLEM.Ui.Elements {
/// </summary>
public static readonly Rule DefaultRule = (field, add) => {
foreach (var c in add) {
if (char.IsControl(c))
if (char.IsControl(c) && (!field.Multiline || c != '\n'))
return false;
}
return true;
@ -135,13 +135,12 @@ namespace MLEM.Ui.Elements {
/// This is always between 0 and the <see cref="string.Length"/> of <see cref="Text"/>
/// </summary>
public int CaretPos {
get {
this.CaretPos = MathHelper.Clamp(this.caretPos, 0, this.text.Length);
return this.caretPos;
}
get => this.caretPos;
set {
if (this.caretPos != value) {
this.caretPos = value;
var val = MathHelper.Clamp(value, 0, this.text.Length);
if (this.caretPos != val) {
this.caretPos = val;
this.caretBlinkTimer = 0;
this.HandleTextChange(false);
}
}
@ -158,6 +157,23 @@ namespace MLEM.Ui.Elements {
this.HandleTextChange(false);
}
}
/// <summary>
/// The maximum amount of characters that can be input into this text field.
/// If this is set, the length of <see cref="Text"/> will never exceed this value.
/// </summary>
public int? MaximumCharacters;
/// <summary>
/// Whether this text field should support multi-line editing.
/// If this is true, pressing <see cref="Keys.Enter"/> will insert a new line into the <see cref="Text"/> if the <see cref="InputRule"/> 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.
/// </summary>
public bool Multiline {
get => this.multiline;
set {
this.multiline = value;
this.HandleTextChange(false);
}
}
private readonly StringBuilder text = new StringBuilder();
@ -165,7 +181,9 @@ namespace MLEM.Ui.Elements {
private double caretBlinkTimer;
private string displayedText;
private int textOffset;
private int lineOffset;
private int caretPos;
private bool multiline;
/// <summary>
/// Creates a new text field with the given settings
@ -175,8 +193,10 @@ namespace MLEM.Ui.Elements {
/// <param name="rule">The text field's input rule</param>
/// <param name="font">The font to use for drawing text</param>
/// <param name="text">The text that the text field should contain by default</param>
public TextField(Anchor anchor, Vector2 size, Rule rule = null, GenericFont font = null, string text = null) : base(anchor, size) {
/// <param name="multiline">Whether the text field should support multi-line editing</param>
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)
@ -195,6 +215,8 @@ namespace MLEM.Ui.Elements {
}
} else if (key == Keys.Delete) {
this.RemoveText(this.CaretPos, 1);
} else if (this.Multiline && key == Keys.Enter) {
this.InsertText('\n');
} else {
this.InsertText(character);
}
@ -206,7 +228,7 @@ namespace MLEM.Ui.Elements {
var title = this.MobileTitle ?? this.PlaceholderText;
var result = await MlemPlatform.Current.OpenOnScreenKeyboard(title, this.MobileDescription, this.Text, false);
if (result != null)
this.SetText(result.Replace('\n', ' '), true);
this.SetText(this.Multiline ? result : result.Replace('\n', ' '), true);
}
}
@ -214,26 +236,83 @@ namespace MLEM.Ui.Elements {
// not initialized yet
if (!this.Font.HasValue())
return;
var length = this.Font.Value.MeasureString(this.text.ToString()).X * this.TextScale;
var maxWidth = this.DisplayArea.Width / this.Scale - this.TextOffsetX * 2;
if (length > maxWidth) {
// if we're moving the caret to the left
if (this.textOffset > this.CaretPos) {
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;
if (this.Multiline) {
// soft wrap if we're multiline
this.displayedText = this.Font.Value.SplitString(this.text.ToString(), maxWidth, this.TextScale);
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();
// calculate what line the caret is on
var caretLine = 0;
var originalIndex = 0;
var addedLineBreaks = 0;
for (var i = 0; i <= this.CaretPos + addedLineBreaks - 1 && i < this.displayedText.Length; i++) {
if (this.displayedText[i] == '\n') {
caretLine++;
if (this.text[originalIndex] != '\n') {
addedLineBreaks++;
continue;
}
}
originalIndex++;
}
// when we're multiline, the text offset is measured in lines
if (this.lineOffset > caretLine) {
// if we're moving up
this.lineOffset = caretLine;
} else if (caretLine >= maxLines) {
// if we're moving down
var limit = caretLine - (maxLines - 1);
if (limit > this.lineOffset)
this.lineOffset = limit;
}
// calculate resulting string
var ret = new StringBuilder();
var lines = 0;
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;
}
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;
// 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;
}
}
if (this.MaskingCharacter != null)
this.displayedText = new string(this.MaskingCharacter.Value, this.displayedText.Length);
@ -288,13 +367,38 @@ namespace MLEM.Ui.Elements {
if (this.displayedText != null) {
var lineHeight = this.Font.Value.LineHeight * this.TextScale * this.Scale;
var textPos = this.DisplayArea.Location + new Vector2(this.TextOffsetX * this.Scale, this.DisplayArea.Height / 2 - lineHeight / 2);
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 textSize = this.Font.Value.MeasureString(this.displayedText.Substring(0, this.CaretPos - this.textOffset)) * this.TextScale * this.Scale;
batch.Draw(batch.GetBlankTexture(), new RectangleF(textPos.X + textSize.X, textPos.Y, this.CaretWidth * this.Scale, lineHeight), null, textColor * alpha);
if (this.IsSelected && this.caretBlinkTimer < 0.5F) {
var caretDrawPos = textPos;
if (this.Multiline) {
var lines = 0;
var lastLineBreak = 0;
var originalIndex = 0;
var addedLineBreaks = 0;
for (var i = 0; i <= this.CaretPos - this.textOffset + addedLineBreaks - 1 && i < this.displayedText.Length; i++) {
if (this.displayedText[i] == '\n') {
lines++;
lastLineBreak = i;
if (this.text[originalIndex] != '\n') {
addedLineBreaks++;
continue;
}
}
originalIndex++;
}
var sub = this.displayedText.Substring(lastLineBreak, this.CaretPos - this.textOffset + addedLineBreaks - lastLineBreak);
caretDrawPos += new Vector2(this.Font.Value.MeasureString(sub).X * this.TextScale * this.Scale, lineHeight * lines);
} else {
caretDrawPos.X += this.Font.Value.MeasureString(this.displayedText.Substring(0, this.CaretPos - this.textOffset)).X * 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);
@ -305,33 +409,40 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// Replaces this text field's text with the given text.
/// If the resulting <see cref="Text"/> exceeds <see cref="MaximumCharacters"/>, the end will be cropped to fit.
/// </summary>
/// <param name="text">The new text</param>
/// <param name="removeMismatching">If any characters that don't match the <see cref="InputRule"/> should be left out</param>
public void SetText(object text, bool removeMismatching = false) {
var strg = text?.ToString() ?? string.Empty;
if (removeMismatching) {
var result = new StringBuilder();
foreach (var c in text.ToString()) {
foreach (var c in strg) {
if (this.InputRule(this, c.ToCachedString()))
result.Append(c);
}
text = result.ToString();
} else if (!this.InputRule(this, text.ToString()))
strg = result.ToString();
} else if (!this.InputRule(this, strg))
return;
if (this.MaximumCharacters != null && strg.Length > this.MaximumCharacters)
strg = strg.Substring(0, this.MaximumCharacters.Value);
this.text.Clear();
this.text.Append(text);
this.text.Append(strg);
this.CaretPos = this.text.Length;
this.HandleTextChange();
}
/// <summary>
/// Inserts the given text at the <see cref="CaretPos"/>
/// Inserts the given text at the <see cref="CaretPos"/>.
/// If the resulting <see cref="Text"/> exceeds <see cref="MaximumCharacters"/>, the end will be cropped to fit.
/// </summary>
/// <param name="text">The text to insert</param>
public void InsertText(object text) {
var strg = text.ToString();
if (!this.InputRule(this, strg))
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();
@ -346,6 +457,8 @@ namespace MLEM.Ui.Elements {
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();
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@ -160,22 +161,36 @@ namespace MLEM.Font {
/// <summary>
/// Splits a string to a given maximum width, adding newline characters between each line.
/// Also splits long words and supports zero-width spaces.
/// Also splits long words and supports zero-width spaces and takes into account existing newline characters in the passed <paramref name="text"/>.
/// See <see cref="SplitStringSeparate"/> for a method that differentiates between existing newline characters and splits due to maximum width.
/// </summary>
/// <param name="text">The text to split into multiple lines</param>
/// <param name="width">The maximum width that each line should have</param>
/// <param name="scale">The scale to use for width measurements</param>
/// <returns>The split string, containing newline characters at each new line</returns>
public string SplitString(string text, float width, float scale) {
var ret = new StringBuilder();
return string.Join("\n", this.SplitStringSeparate(text, width, scale));
}
/// <summary>
/// Splits a string to a given maximum width and returns each split section as a separate string.
/// Note that existing new lines are taken into account for line length, but not split in the resulting strings.
/// This method differs from <see cref="SplitString"/> in that it differentiates between pre-existing newline characters and splits due to maximum width.
/// </summary>
/// <param name="text">The text to split into multiple lines</param>
/// <param name="width">The maximum width that each line should have</param>
/// <param name="scale">The scale to use for width measurements</param>
/// <returns>The split string as an enumerable of split sections</returns>
public IEnumerable<string> SplitStringSeparate(string text, float width, float scale) {
var currWidth = 0F;
var lastSpaceIndex = -1;
var widthSinceLastSpace = 0F;
var curr = new StringBuilder();
for (var i = 0; i < text.Length; i++) {
var c = text[i];
if (c == '\n') {
// split at pre-defined new lines
ret.Append(c);
// fake split at pre-defined new lines
curr.Append(c);
lastSpaceIndex = -1;
widthSinceLastSpace = 0;
currWidth = 0;
@ -183,17 +198,19 @@ namespace MLEM.Font {
var cWidth = this.MeasureString(c.ToCachedString()).X * scale;
if (c == ' ' || c == OneEmSpace || c == Zwsp) {
// remember the location of this (breaking!) space
lastSpaceIndex = ret.Length;
lastSpaceIndex = curr.Length;
widthSinceLastSpace = 0;
} else if (currWidth + cWidth >= width) {
// check if this line contains a space
if (lastSpaceIndex < 0) {
// if there is no last space, the word is longer than a line so we split here
ret.Append('\n');
yield return curr.ToString();
currWidth = 0;
curr.Clear();
} else {
// split after the last space
ret.Insert(lastSpaceIndex + 1, '\n');
yield return curr.ToString().Substring(0, lastSpaceIndex + 1);
curr.Remove(0, lastSpaceIndex + 1);
// we need to restore the width accumulated since the last space for the new line
currWidth = widthSinceLastSpace;
}
@ -204,10 +221,11 @@ namespace MLEM.Font {
// add current character
currWidth += cWidth;
widthSinceLastSpace += cWidth;
ret.Append(c);
curr.Append(c);
}
}
return ret.ToString();
if (curr.Length > 0)
yield return curr.ToString();
}
private static bool IsTrailingSpace(string s, int index) {

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

View file

@ -29,9 +29,9 @@ namespace Tests {
[Test]
public void TestRegularSplit() {
Assert.AreEqual(this.font.SplitString(
Assert.AreEqual(this.font.SplitStringSeparate(
"Note that the default style does not contain any textures or font files and, as such, is quite bland. However, the default style is quite easy to override, as can be seen in the code for this demo.",
65, 0.1F), "Note that the default style does \nnot contain any textures or font \nfiles and, as such, is quite \nbland. However, the default \nstyle is quite easy to override, \nas can be seen in the code for \nthis demo.");
65, 0.1F), new[] {"Note that the default style does ", "not contain any textures or font ", "files and, as such, is quite ", "bland. However, the default ", "style is quite easy to override, ", "as can be seen in the code for ", "this demo."});
var formatted = this.formatter.Tokenize(this.font,
"Select the demo you want to see below using your mouse, touch input, your keyboard or a controller. Check the demos' <c CornflowerBlue><l https://github.com/Ellpeck/MLEM/tree/main/Demos>source code</l></c> for more in-depth explanations of their functionality or the <c CornflowerBlue><l https://mlem.ellpeck.de/>website</l></c> for tutorials and API documentation.");
@ -55,16 +55,16 @@ namespace Tests {
[Test]
public void TestLongLineSplit() {
const string expectedDisplay = "This_is_a_really_long_line_to_s\nee_if_splitting_without_spaces_\nworks_properly._I_also_want_to_\nsee_if_it_works_across_multiple\n_lines_or_just_on_the_first_one. \nBut after this, I want the text to \ncontinue normally before \nchanging_back_to_being_really_\nlong_oh_yes";
var expectedDisplay = new[] {"This_is_a_really_long_line_to_s", "ee_if_splitting_without_spaces_", "works_properly._I_also_want_to_", "see_if_it_works_across_multiple", "_lines_or_just_on_the_first_one. ", "But after this, I want the text to ", "continue normally before ", "changing_back_to_being_really_", "long_oh_yes"};
Assert.AreEqual(this.font.SplitString(
Assert.AreEqual(this.font.SplitStringSeparate(
"This_is_a_really_long_line_to_see_if_splitting_without_spaces_works_properly._I_also_want_to_see_if_it_works_across_multiple_lines_or_just_on_the_first_one. But after this, I want the text to continue normally before changing_back_to_being_really_long_oh_yes",
65, 0.1F), expectedDisplay);
var formatted = this.formatter.Tokenize(this.font,
"This_is_a_really_long_line_to_see_if_<c Blue>splitting</c>_without_spaces_works_properly._I_also_want_to_see_if_it_works_across_multiple_<c Yellow>lines</c>_or_just_on_the_first_one. But after this, I want the <b>text</b> to continue normally before changing_back_<i>to</i>_being_really_long_oh_yes");
formatted.Split(this.font, 65, 0.1F);
Assert.AreEqual(formatted.DisplayString, expectedDisplay);
Assert.AreEqual(formatted.DisplayString, string.Join('\n', expectedDisplay));
var tokens = new[] {
"This_is_a_really_long_line_to_s\nee_if_",