From 96c0ca5cbabfb7ea64398ca53d98fa3a10a06503 Mon Sep 17 00:00:00 2001 From: Ben H Date: Sun, 1 Sep 2024 11:13:12 -0400 Subject: [PATCH] 5.2.0 release: improved performance of text wrapping --- ...MakesGames.PlayPlayMini.Performance.csproj | 18 +++ .../Program.cs | 4 + .../WrapText.cs | 16 ++ .../WordWrapTests.cs | 13 ++ BenMakesGames.PlayPlayMini.sln | 6 + .../BenMakesGames.PlayPlayMini.csproj | 2 +- .../Extensions/StringExtensions.cs | 152 ++++++++++++++++-- 7 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 BenMakesGames.PlayPlayMini.Performance/BenMakesGames.PlayPlayMini.Performance.csproj create mode 100644 BenMakesGames.PlayPlayMini.Performance/Program.cs create mode 100644 BenMakesGames.PlayPlayMini.Performance/WrapText.cs diff --git a/BenMakesGames.PlayPlayMini.Performance/BenMakesGames.PlayPlayMini.Performance.csproj b/BenMakesGames.PlayPlayMini.Performance/BenMakesGames.PlayPlayMini.Performance.csproj new file mode 100644 index 0000000..07b6d14 --- /dev/null +++ b/BenMakesGames.PlayPlayMini.Performance/BenMakesGames.PlayPlayMini.Performance.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + diff --git a/BenMakesGames.PlayPlayMini.Performance/Program.cs b/BenMakesGames.PlayPlayMini.Performance/Program.cs new file mode 100644 index 0000000..329e538 --- /dev/null +++ b/BenMakesGames.PlayPlayMini.Performance/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using BenMakesGames.PlayPlayMini.Performance; + +BenchmarkRunner.Run(); diff --git a/BenMakesGames.PlayPlayMini.Performance/WrapText.cs b/BenMakesGames.PlayPlayMini.Performance/WrapText.cs new file mode 100644 index 0000000..c85f047 --- /dev/null +++ b/BenMakesGames.PlayPlayMini.Performance/WrapText.cs @@ -0,0 +1,16 @@ +using System.Text; +using BenchmarkDotNet.Attributes; +using BenMakesGames.PlayPlayMini.Extensions; +using BenMakesGames.PlayPlayMini.Model; + +namespace BenMakesGames.PlayPlayMini.Performance; + +[MemoryDiagnoser(false)] +public class WrapText +{ + private static readonly Font Font = new(null!, 6, 8, 0, 0, ' '); + private static readonly string Text = "Far out in the uncharted backwaters of the unfashionable end of the western spiral arm of the Galaxy lies a small unregarded yellow sun."; + + [Benchmark] + public string Original() => Text.WrapText(Font, 100); +} diff --git a/BenMakesGames.PlayPlayMini.Tests/WordWrapTests.cs b/BenMakesGames.PlayPlayMini.Tests/WordWrapTests.cs index 8f6d085..8ffc806 100644 --- a/BenMakesGames.PlayPlayMini.Tests/WordWrapTests.cs +++ b/BenMakesGames.PlayPlayMini.Tests/WordWrapTests.cs @@ -1,3 +1,4 @@ +using BenMakesGames.PlayPlayMini.Extensions; using BenMakesGames.PlayPlayMini.Model; using BenMakesGames.PlayPlayMini.Services; using FluentAssertions; @@ -35,4 +36,16 @@ public void GraphicsManagerComputeDimensionsWithWordWrap_ReturnsExpected(int cha actualWidth.Should().Be(expectedWidth); actualHeight.Should().Be(expectedHeight); } + + private static readonly Font Font = new(null!, 1, 1, 0, 0, ' '); + + [Theory] + [InlineData("Hello, world", 6, "Hello,\nworld")] + [InlineData("Hello, world", 7, "Hello,\nworld")] + [InlineData("Hello, world", 11, "Hello,\nworld")] + [InlineData("Hello, world", 12, "Hello, world")] + public void StringExtensionsWordWrap_ReturnsExpected(string originalText, int maxWidth, string expectedText) + { + originalText.WrapText(Font, maxWidth).Should().Be(expectedText); + } } diff --git a/BenMakesGames.PlayPlayMini.sln b/BenMakesGames.PlayPlayMini.sln index a82f937..c44164a 100644 --- a/BenMakesGames.PlayPlayMini.sln +++ b/BenMakesGames.PlayPlayMini.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenMakesGames.PlayPlayMini. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenMakesGames.PlayPlayMini.NAudio", "BenMakesGames.PlayPlayMini.NAudio\BenMakesGames.PlayPlayMini.NAudio.csproj", "{9BDBBE24-8240-414F-B8F7-C09B89C98861}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenMakesGames.PlayPlayMini.Performance", "BenMakesGames.PlayPlayMini.Performance\BenMakesGames.PlayPlayMini.Performance.csproj", "{CDC52531-262F-45F2-9485-D5C09BACE008}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +55,10 @@ Global {9BDBBE24-8240-414F-B8F7-C09B89C98861}.Debug|Any CPU.Build.0 = Debug|Any CPU {9BDBBE24-8240-414F-B8F7-C09B89C98861}.Release|Any CPU.ActiveCfg = Release|Any CPU {9BDBBE24-8240-414F-B8F7-C09B89C98861}.Release|Any CPU.Build.0 = Release|Any CPU + {CDC52531-262F-45F2-9485-D5C09BACE008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDC52531-262F-45F2-9485-D5C09BACE008}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDC52531-262F-45F2-9485-D5C09BACE008}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDC52531-262F-45F2-9485-D5C09BACE008}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj b/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj index 98df353..345efd6 100644 --- a/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj +++ b/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj @@ -5,7 +5,7 @@ Ben Hendel-Doying An opinionated framework for making smallish games with MonoGame. 2021-2024 Ben Hendel-Doying - 5.1.0 + 5.2.0 true monogame game engine framework di state diff --git a/BenMakesGames.PlayPlayMini/Extensions/StringExtensions.cs b/BenMakesGames.PlayPlayMini/Extensions/StringExtensions.cs index adc1e7e..6deaee2 100644 --- a/BenMakesGames.PlayPlayMini/Extensions/StringExtensions.cs +++ b/BenMakesGames.PlayPlayMini/Extensions/StringExtensions.cs @@ -16,38 +16,36 @@ public static class StringExtensions public static string WrapText(this string text, Font font, int maxWidth) { if (string.IsNullOrEmpty(text)) - { return string.Empty; - } var result = new StringBuilder(); - var lines = text.Split([ "\r\n", "\r", "\n" ], StringSplitOptions.None); + var lines = new LineSplitEnumerator(text); - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + foreach(var (line, _) in lines) { - var words = lines[lineIndex].Split(' '); + var words = new SpaceSplitEnumerator(line); var lineLength = 0; - if(lineIndex > 0) + if(result.Length > 0) result.Append('\n'); - for (int wordIndex = 0; wordIndex < words.Length; wordIndex++) + foreach(var (word, _) in words) { - var wordWidth = words[wordIndex].Length * font.CharacterWidth + (words[wordIndex].Length - 1) * font.HorizontalSpacing; + var wordWidth = word.Length * font.CharacterWidth + (word.Length - 1) * font.HorizontalSpacing; // we might be prepending a space: if(lineLength > 0) wordWidth += font.CharacterWidth + font.HorizontalSpacing * 2; - + if (lineLength + wordWidth > maxWidth) { result.Append('\n'); - + if(lineLength > 0) wordWidth -= font.CharacterWidth + font.HorizontalSpacing * 2; // if we prepended a space, take it off again... - + lineLength = 0; } else if (lineLength > 0) @@ -55,8 +53,8 @@ public static string WrapText(this string text, Font font, int maxWidth) result.Append(' '); } - result.Append(words[wordIndex]); - + result.Append(word); + lineLength += wordWidth; } } @@ -64,3 +62,131 @@ public static string WrapText(this string text, Font font, int maxWidth) return result.ToString(); } } + +/// +/// Supports zero-allocation splitting of a string into lines. +/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm +/// +/// +/// var lines = new LineSplitEnumerator("Text\nto\r\nsplit"); +/// +/// foreach(var (line, _) in lines) +/// { +/// // do something with "line" +/// } +/// +public ref struct LineSplitEnumerator +{ + private ReadOnlySpan _str; + + public LineSplitEnumerator(ReadOnlySpan str) + { + _str = str; + Current = default; + } + + // Needed to be compatible with the foreach operator + public LineSplitEnumerator GetEnumerator() => this; + + public bool MoveNext() + { + var span = _str; + if (span.Length == 0) // Reach the end of the string + return false; + + var index = span.IndexOfAny('\r', '\n'); + if (index == -1) // The string is composed of only one line + { + _str = ReadOnlySpan.Empty; // The remaining string is an empty string + Current = new LineSplitEntry(span, ReadOnlySpan.Empty); + return true; + } + + if (index < span.Length - 1 && span[index] == '\r') + { + // Try to consume the '\n' associated to the '\r' + var next = span[index + 1]; + if (next == '\n') + { + Current = new LineSplitEntry(span.Slice(0, index), span.Slice(index, 2)); + _str = span.Slice(index + 2); + return true; + } + } + + Current = new LineSplitEntry(span.Slice(0, index), span.Slice(index, 1)); + _str = span.Slice(index + 1); + return true; + } + + public LineSplitEntry Current { get; private set; } +} + +/// +/// Supports zero-allocation splitting of a string into words. +/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm +/// +public ref struct SpaceSplitEnumerator +{ + private ReadOnlySpan _str; + + public SpaceSplitEnumerator(ReadOnlySpan str) + { + _str = str; + Current = default; + } + + // Needed to be compatible with the foreach operator + public SpaceSplitEnumerator GetEnumerator() => this; + + public bool MoveNext() + { + var span = _str; + if (span.Length == 0) // Reach the end of the string + return false; + + var index = span.IndexOf(' '); + if (index == -1) // The string is composed of only one line + { + _str = ReadOnlySpan.Empty; // The remaining string is an empty string + Current = new LineSplitEntry(span, ReadOnlySpan.Empty); + return true; + } + + Current = new LineSplitEntry(span.Slice(0, index), span.Slice(index, 1)); + _str = span.Slice(index + 1); + return true; + } + + public LineSplitEntry Current { get; private set; } +} + +/// +/// Supports zero-allocation splitting of a string into lines. +/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm +/// +public readonly ref struct LineSplitEntry +{ + public LineSplitEntry(ReadOnlySpan line, ReadOnlySpan separator) + { + Line = line; + Separator = separator; + } + + public ReadOnlySpan Line { get; } + public ReadOnlySpan Separator { get; } + + // This method allow to deconstruct the type, so you can write any of the following code + // foreach (var entry in str.SplitLines()) { _ = entry.Line; } + // foreach (var (line, endOfLine) in str.SplitLines()) { _ = line; } + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct?WT.mc_id=DT-MVP-5003978#deconstructing-user-defined-types + public void Deconstruct(out ReadOnlySpan line, out ReadOnlySpan separator) + { + line = Line; + separator = Separator; + } + + // This method allow to implicitly cast the type into a ReadOnlySpan, so you can write the following code + // foreach (ReadOnlySpan entry in str.SplitLines()) + public static implicit operator ReadOnlySpan(LineSplitEntry entry) => entry.Line; +}