Skip to content

Commit

Permalink
5.2.0 release: improved performance of text wrapping
Browse files Browse the repository at this point in the history
  • Loading branch information
BenMakesGames committed Sep 1, 2024
1 parent 4f22ed0 commit 96c0ca5
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\BenMakesGames.PlayPlayMini\BenMakesGames.PlayPlayMini.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>

</Project>
4 changes: 4 additions & 0 deletions BenMakesGames.PlayPlayMini.Performance/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using BenchmarkDotNet.Running;
using BenMakesGames.PlayPlayMini.Performance;

BenchmarkRunner.Run<WrapText>();
16 changes: 16 additions & 0 deletions BenMakesGames.PlayPlayMini.Performance/WrapText.cs
Original file line number Diff line number Diff line change
@@ -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);
}
13 changes: 13 additions & 0 deletions BenMakesGames.PlayPlayMini.Tests/WordWrapTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using BenMakesGames.PlayPlayMini.Extensions;
using BenMakesGames.PlayPlayMini.Model;
using BenMakesGames.PlayPlayMini.Services;
using FluentAssertions;
Expand Down Expand Up @@ -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);
}
}
6 changes: 6 additions & 0 deletions BenMakesGames.PlayPlayMini.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Company>Ben Hendel-Doying</Company>
<Description>An opinionated framework for making smallish games with MonoGame.</Description>
<Copyright>2021-2024 Ben Hendel-Doying</Copyright>
<Version>5.1.0</Version>
<Version>5.2.0</Version>

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageTags>monogame game engine framework di state</PackageTags>
Expand Down
152 changes: 139 additions & 13 deletions BenMakesGames.PlayPlayMini/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,177 @@ 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)
{
result.Append(' ');
}

result.Append(words[wordIndex]);
result.Append(word);

lineLength += wordWidth;
}
}

return result.ToString();
}
}

/// <summary>
/// Supports zero-allocation splitting of a string into lines.
/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm
/// </summary>
/// <example><code>
/// var lines = new LineSplitEnumerator("Text\nto\r\nsplit");
///
/// foreach(var (line, _) in lines)
/// {
/// // do something with "line"
/// }
/// </code></example>
public ref struct LineSplitEnumerator
{
private ReadOnlySpan<char> _str;

public LineSplitEnumerator(ReadOnlySpan<char> 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<char>.Empty; // The remaining string is an empty string
Current = new LineSplitEntry(span, ReadOnlySpan<char>.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; }
}

/// <summary>
/// Supports zero-allocation splitting of a string into words.
/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm
/// </summary>
public ref struct SpaceSplitEnumerator
{
private ReadOnlySpan<char> _str;

public SpaceSplitEnumerator(ReadOnlySpan<char> 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<char>.Empty; // The remaining string is an empty string
Current = new LineSplitEntry(span, ReadOnlySpan<char>.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; }
}

/// <summary>
/// Supports zero-allocation splitting of a string into lines.
/// From https://www.meziantou.net/split-a-string-into-lines-without-allocation.htm
/// </summary>
public readonly ref struct LineSplitEntry
{
public LineSplitEntry(ReadOnlySpan<char> line, ReadOnlySpan<char> separator)
{
Line = line;
Separator = separator;
}

public ReadOnlySpan<char> Line { get; }
public ReadOnlySpan<char> 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<char> line, out ReadOnlySpan<char> separator)
{
line = Line;
separator = Separator;
}

// This method allow to implicitly cast the type into a ReadOnlySpan<char>, so you can write the following code
// foreach (ReadOnlySpan<char> entry in str.SplitLines())
public static implicit operator ReadOnlySpan<char>(LineSplitEntry entry) => entry.Line;
}

0 comments on commit 96c0ca5

Please sign in to comment.