From 14940d39c5ba192fd186712ca038fd819d2542e0 Mon Sep 17 00:00:00 2001 From: Ellpeck Date: Fri, 25 Jun 2021 15:23:30 +0200 Subject: [PATCH] added text alignment options to tokenized strings and paragraphs --- Demos/UiDemo.cs | 9 ++ MLEM.Ui/Elements/Paragraph.cs | 37 +++++-- MLEM/Font/GenericFont.cs | 24 ++++- MLEM/Formatting/TextAlignment.cs | 22 ++++ MLEM/Formatting/TextFormatter.cs | 5 +- MLEM/Formatting/Token.cs | 4 + MLEM/Formatting/TokenizedString.cs | 159 ++++++++++++++++++----------- 7 files changed, 188 insertions(+), 72 deletions(-) create mode 100644 MLEM/Formatting/TextAlignment.cs diff --git a/Demos/UiDemo.cs b/Demos/UiDemo.cs index c2e7735..7379a03 100644 --- a/Demos/UiDemo.cs +++ b/Demos/UiDemo.cs @@ -6,6 +6,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MLEM.Extensions; using MLEM.Font; +using MLEM.Formatting; using MLEM.Formatting.Codes; using MLEM.Misc; using MLEM.Startup; @@ -191,6 +192,14 @@ namespace Demos { this.root.AddChild(new VerticalSpace(3)); this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled button", "This button can't be clicked or moved to using automatic navigation") {IsDisabled = true}).PositionOffset = new Vector2(0, 1); + const string alignText = "Paragraphs can have left aligned text, right aligned text and center aligned text."; + this.root.AddChild(new VerticalSpace(3)); + var alignPar = this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, alignText)); + alignPar.LinkAction = (l, c) => { + if (Enum.TryParse(c.Match.Groups[1].Value, out var alignment)) + alignPar.Alignment = alignment; + }; + this.root.AddChild(new VerticalSpace(3)); this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "The code for this demo contains some examples for how to query element data. This is the output of that:")); diff --git a/MLEM.Ui/Elements/Paragraph.cs b/MLEM.Ui/Elements/Paragraph.cs index e6adf4d..3f5276a 100644 --- a/MLEM.Ui/Elements/Paragraph.cs +++ b/MLEM.Ui/Elements/Paragraph.cs @@ -16,7 +16,6 @@ namespace MLEM.Ui.Elements { /// public class Paragraph : Element { - private string text; /// /// The font that this paragraph draws text with. /// To set its bold and italic font, use and . @@ -84,6 +83,20 @@ namespace MLEM.Ui.Elements { /// By default, is executed. /// public Action LinkAction; + /// + /// The that this paragraph's text should be rendered with + /// + public TextAlignment Alignment { + get => this.alignment; + set { + this.alignment = value; + this.SetAreaDirty(); + this.TokenizedText = null; + } + } + + private string text; + private TextAlignment alignment; /// /// Creates a new paragraph with the given settings. @@ -128,10 +141,10 @@ namespace MLEM.Ui.Elements { /// public override void Draw(GameTime time, SpriteBatch batch, float alpha, BlendState blendState, SamplerState samplerState, Matrix matrix) { - var pos = this.DisplayArea.Location; + var pos = this.DisplayArea.Location + new Vector2(GetAlignmentOffset(), 0); var sc = this.TextScale * this.TextScaleMultiplier * this.Scale; var color = this.TextColor.OrDefault(Color.White) * alpha; - this.TokenizedText.Draw(time, batch, pos, this.RegularFont, color, sc, 0); + this.TokenizedText.Draw(time, batch, pos, this.RegularFont, color, sc, 0, this.Alignment); base.Draw(time, batch, alpha, blendState, samplerState, matrix); } @@ -151,7 +164,7 @@ namespace MLEM.Ui.Elements { protected virtual void ParseText(Vector2 size) { if (this.TokenizedText == null) { // tokenize the text - this.TokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text); + this.TokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text, this.Alignment); // add links to the paragraph this.RemoveChildren(c => c is Link); @@ -162,9 +175,9 @@ namespace MLEM.Ui.Elements { var width = size.X - this.ScaledPadding.Width; var scale = this.TextScale * this.TextScaleMultiplier * this.Scale; if (this.TruncateIfLong) { - this.TokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis); + this.TokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis, this.Alignment); } else { - this.TokenizedText.Split(this.RegularFont, width, scale); + this.TokenizedText.Split(this.RegularFont, width, scale, this.Alignment); } } @@ -173,6 +186,16 @@ namespace MLEM.Ui.Elements { this.Text = this.GetTextCallback(this); } + private float GetAlignmentOffset() { + switch (this.Alignment) { + case TextAlignment.Center: + return this.DisplayArea.Width / 2; + case TextAlignment.Right: + return this.DisplayArea.Width; + } + return 0; + } + /// /// A delegate method used for /// @@ -214,7 +237,7 @@ namespace MLEM.Ui.Elements { public override void ForceUpdateArea() { // set the position offset and size to the token's first area var area = this.Token.GetArea(Vector2.Zero, this.textScale).First(); - this.PositionOffset = area.Location; + this.PositionOffset = area.Location + new Vector2(((Paragraph) this.Parent).GetAlignmentOffset() / this.Parent.Scale, 0); this.Size = area.Size; base.ForceUpdateArea(); } diff --git a/MLEM/Font/GenericFont.cs b/MLEM/Font/GenericFont.cs index 14fe4e7..17f0c6c 100644 --- a/MLEM/Font/GenericFont.cs +++ b/MLEM/Font/GenericFont.cs @@ -70,13 +70,13 @@ namespace MLEM.Font { } /// - public Vector2 MeasureString(string text) { + public Vector2 MeasureString(string text, bool ignoreTrailingSpaces = false) { var size = Vector2.Zero; if (text.Length <= 0) return size; var xOffset = 0F; - foreach (var c in text) { - switch (c) { + for (var i = 0; i < text.Length; i++) { + switch (text[i]) { case '\n': xOffset = 0; size.Y += this.LineHeight; @@ -90,8 +90,16 @@ namespace MLEM.Font { case Zwsp: // don't add width for a zero-width space break; + case ' ': + if (ignoreTrailingSpaces && IsTrailingSpace(text, i)) { + // if this is a trailing space, we can skip remaining spaces too + i = text.Length - 1; + break; + } + xOffset += this.MeasureChar(' ').X; + break; default: - xOffset += this.MeasureChar(c).X; + xOffset += this.MeasureChar(text[i]).X; break; } // increase x size if this line is the longest @@ -186,5 +194,13 @@ namespace MLEM.Font { return ret.ToString(); } + private static bool IsTrailingSpace(string s, int index) { + for (var i = index + 1; i < s.Length; i++) { + if (s[i] != ' ') + return false; + } + return true; + } + } } \ No newline at end of file diff --git a/MLEM/Formatting/TextAlignment.cs b/MLEM/Formatting/TextAlignment.cs new file mode 100644 index 0000000..3f7876a --- /dev/null +++ b/MLEM/Formatting/TextAlignment.cs @@ -0,0 +1,22 @@ +namespace MLEM.Formatting { + /// + /// An enumeration that represents a set of alignment options for objects and MLEM.Ui paragraphs. + /// + public enum TextAlignment { + + /// + /// Left alignment, which is also the default value + /// + Left, + /// + /// Center alignment + /// + Center, + /// + /// Right alignment. + /// In this alignment option, trailing spaces are ignored to ensure that visual alignment is consistent. + /// + Right + + } +} \ No newline at end of file diff --git a/MLEM/Formatting/TextFormatter.cs b/MLEM/Formatting/TextFormatter.cs index 2a24fdb..6248ef0 100644 --- a/MLEM/Formatting/TextFormatter.cs +++ b/MLEM/Formatting/TextFormatter.cs @@ -67,8 +67,9 @@ namespace MLEM.Formatting { /// /// The font to use for tokenization. Note that this font needs to be the same that will later be used for splitting, measuring and/or drawing. /// The string to tokenize + /// The text alignment that should be used. Note that this alignment needs to be the same that will later be used for splitting, measuring and/or drawing. /// - public TokenizedString Tokenize(GenericFont font, string s) { + public TokenizedString Tokenize(GenericFont font, string s, TextAlignment alignment = TextAlignment.Left) { // resolve macros s = this.ResolveMacros(s); var tokens = new List(); @@ -101,7 +102,7 @@ namespace MLEM.Formatting { codes.RemoveAll(c => c.EndsHere(next)); codes.Add(next); } - return new TokenizedString(font, s, StripFormatting(font, s, tokens.SelectMany(t => t.AppliedCodes)), tokens.ToArray()); + return new TokenizedString(font, alignment, s, StripFormatting(font, s, tokens.SelectMany(t => t.AppliedCodes)), tokens.ToArray()); } /// diff --git a/MLEM/Formatting/Token.cs b/MLEM/Formatting/Token.cs index c51d956..460e05f 100644 --- a/MLEM/Formatting/Token.cs +++ b/MLEM/Formatting/Token.cs @@ -33,6 +33,10 @@ namespace MLEM.Formatting { /// public string DisplayString => this.ModifiedSubstring ?? this.Substring; /// + /// The , but split at newline characters + /// + public string[] SplitDisplayString { get; internal set; } + /// /// The substring that this token contains, without the formatting codes removed. /// public readonly string RawSubstring; diff --git a/MLEM/Formatting/TokenizedString.cs b/MLEM/Formatting/TokenizedString.cs index 153ccf7..a990ba2 100644 --- a/MLEM/Formatting/TokenizedString.cs +++ b/MLEM/Formatting/TokenizedString.cs @@ -38,13 +38,13 @@ namespace MLEM.Formatting { public readonly Code[] AllCodes; private string modifiedString; - internal TokenizedString(GenericFont font, string rawString, string strg, Token[] tokens) { + internal TokenizedString(GenericFont font, TextAlignment alignment, string rawString, string strg, Token[] tokens) { this.RawString = rawString; this.String = strg; this.Tokens = tokens; // since a code can be present in multiple tokens, we use Distinct here this.AllCodes = tokens.SelectMany(t => t.AppliedCodes).Distinct().ToArray(); - this.CalculateTokenAreas(font); + this.RecalculateTokenData(font, alignment); } /// @@ -55,10 +55,11 @@ namespace MLEM.Formatting { /// The font to use for width calculations /// The maximum width, in display pixels based on the font and scale /// The scale to use for width measurements - public void Split(GenericFont font, float width, float scale) { + /// The text alignment that should be used for width calculations + public void Split(GenericFont font, float width, float scale, TextAlignment alignment = TextAlignment.Left) { // a split string has the same character count as the input string but with newline characters added this.modifiedString = font.SplitString(this.String, width, scale); - this.StoreModifiedSubstrings(font); + this.StoreModifiedSubstrings(font, alignment); } /// @@ -70,12 +71,13 @@ namespace MLEM.Formatting { /// The maximum width, in display pixels based on the font and scale /// The scale to use for width measurements /// The characters to add to the end of the string if it is too long - public void Truncate(GenericFont font, float width, float scale, string ellipsis = "") { + /// The text alignment that should be used for width calculations + public void Truncate(GenericFont font, float width, float scale, string ellipsis = "", TextAlignment alignment = TextAlignment.Left) { this.modifiedString = font.TruncateString(this.String, width, scale, false, ellipsis); - this.StoreModifiedSubstrings(font); + this.StoreModifiedSubstrings(font, alignment); } - /// + /// public Vector2 Measure(GenericFont font) { return font.MeasureString(this.DisplayString); } @@ -102,77 +104,116 @@ namespace MLEM.Formatting { } /// - public void Draw(GameTime time, SpriteBatch batch, Vector2 pos, GenericFont font, Color color, float scale, float depth) { - var innerOffset = new Vector2(); - foreach (var token in this.Tokens) { + public void Draw(GameTime time, SpriteBatch batch, Vector2 pos, GenericFont font, Color color, float scale, float depth, TextAlignment alignment = TextAlignment.Left) { + var innerOffset = new Vector2(this.GetInnerOffsetX(font, 0, 0, scale, alignment), 0); + for (var t = 0; t < this.Tokens.Length; t++) { + var token = this.Tokens[t]; var drawFont = token.GetFont(font) ?? font; var drawColor = token.GetColor(color) ?? color; - for (var i = 0; i < token.DisplayString.Length; i++) { - var c = token.DisplayString[i]; - if (c == '\n') { - innerOffset.X = 0; + for (var l = 0; l < token.SplitDisplayString.Length; l++) { + var line = token.SplitDisplayString[l]; + for (var i = 0; i < line.Length; i++) { + var c = line[i]; + if (i == 0) + token.DrawSelf(time, batch, pos + innerOffset, font, color, scale, depth); + + var cString = c.ToCachedString(); + token.DrawCharacter(time, batch, c, cString, i, pos + innerOffset, drawFont, drawColor, scale, depth); + innerOffset.X += font.MeasureString(cString).X * scale; + } + // only split at a new line, not between tokens! + if (l < token.SplitDisplayString.Length - 1) { + innerOffset.X = this.GetInnerOffsetX(font, t, l + 1, scale, alignment); innerOffset.Y += font.LineHeight * scale; } - if (i == 0) - token.DrawSelf(time, batch, pos + innerOffset, font, color, scale, depth); - - var cString = c.ToCachedString(); - token.DrawCharacter(time, batch, c, cString, i, pos + innerOffset, drawFont, drawColor, scale, depth); - innerOffset.X += font.MeasureString(cString).X * scale; } } } - private void StoreModifiedSubstrings(GenericFont font) { - // skip substring logic for unformatted text + private void StoreModifiedSubstrings(GenericFont font, TextAlignment alignment) { if (this.Tokens.Length == 1) { + // skip substring logic for unformatted text this.Tokens[0].ModifiedSubstring = this.modifiedString; - return; + } else { + // this is basically a substring function that ignores added newlines for indexing + var index = 0; + var currToken = 0; + var splitIndex = 0; + var ret = new StringBuilder(); + while (splitIndex < this.modifiedString.Length && currToken < this.Tokens.Length) { + var token = this.Tokens[currToken]; + if (token.Substring.Length > 0) { + ret.Append(this.modifiedString[splitIndex]); + // if the current char is not an added newline, we simulate length increase + if (this.modifiedString[splitIndex] != '\n' || this.String[index] == '\n') + index++; + splitIndex++; + } + // move on to the next token if we reached its end + if (index >= token.Index + token.Substring.Length) { + token.ModifiedSubstring = ret.ToString(); + ret.Clear(); + currToken++; + } + } + // set additional token contents beyond our string in case we truncated + if (ret.Length > 0) + this.Tokens[currToken++].ModifiedSubstring = ret.ToString(); + while (currToken < this.Tokens.Length) + this.Tokens[currToken++].ModifiedSubstring = string.Empty; } - // this is basically a substring function that ignores added newlines for indexing - var index = 0; - var currToken = 0; - var splitIndex = 0; - var ret = new StringBuilder(); - while (splitIndex < this.modifiedString.Length && currToken < this.Tokens.Length) { - var token = this.Tokens[currToken]; - if (token.Substring.Length > 0) { - ret.Append(this.modifiedString[splitIndex]); - // if the current char is not an added newline, we simulate length increase - if (this.modifiedString[splitIndex] != '\n' || this.String[index] == '\n') - index++; - splitIndex++; - } - // move on to the next token if we reached its end - if (index >= token.Index + token.Substring.Length) { - token.ModifiedSubstring = ret.ToString(); - ret.Clear(); - currToken++; - } - } - // set additional token contents beyond our string in case we truncated - if (ret.Length > 0) - this.Tokens[currToken++].ModifiedSubstring = ret.ToString(); - while (currToken < this.Tokens.Length) - this.Tokens[currToken++].ModifiedSubstring = string.Empty; - - this.CalculateTokenAreas(font); + this.RecalculateTokenData(font, alignment); } - private void CalculateTokenAreas(GenericFont font) { - var innerOffset = new Vector2(); - foreach (var token in this.Tokens) { + private float GetInnerOffsetX(GenericFont font, int tokenIndex, int lineIndex, float scale, TextAlignment alignment) { + if (alignment > TextAlignment.Left) { + var rest = this.GetRestOfLineLength(font, tokenIndex, lineIndex) * scale; + if (alignment == TextAlignment.Center) + rest /= 2; + return -rest; + } + return 0; + } + + private float GetRestOfLineLength(GenericFont font, int tokenIndex, int lineIndex) { + var token = this.Tokens[tokenIndex]; + var ret = font.MeasureString(token.SplitDisplayString[lineIndex], true).X; + if (lineIndex >= token.SplitDisplayString.Length - 1) { + // the line ends somewhere in or after the next token + for (var i = tokenIndex + 1; i < this.Tokens.Length; i++) { + var other = this.Tokens[i]; + if (other.SplitDisplayString.Length > 1) { + // the line ends in this token + ret += font.MeasureString(other.SplitDisplayString[0]).X; + break; + } else { + // the line doesn't end in this token, so add it fully + ret += font.MeasureString(other.DisplayString).X; + } + } + } + return ret; + } + + private void RecalculateTokenData(GenericFont font, TextAlignment alignment) { + // split display strings + foreach (var token in this.Tokens) + token.SplitDisplayString = token.DisplayString.Split('\n'); + + // token areas + var innerOffset = new Vector2(this.GetInnerOffsetX(font, 0, 0, 1, alignment), 0); + for (var t = 0; t < this.Tokens.Length; t++) { + var token = this.Tokens[t]; var area = new List(); - var split = token.DisplayString.Split('\n'); - for (var i = 0; i < split.Length; i++) { - var size = font.MeasureString(split[i]); + for (var l = 0; l < token.SplitDisplayString.Length; l++) { + var size = font.MeasureString(token.SplitDisplayString[l]); var rect = new RectangleF(innerOffset, size); if (!rect.IsEmpty) area.Add(rect); - if (i < split.Length - 1) { - innerOffset.X = 0; + if (l < token.SplitDisplayString.Length - 1) { + innerOffset.X = this.GetInnerOffsetX(font, t, l + 1, 1, alignment); innerOffset.Y += font.LineHeight; } else { innerOffset.X += size.X;