From 1d676fcea84e667da973e1a6952798cab282ba64 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Thu, 18 Sep 2025 10:13:40 +0100
Subject: [PATCH 1/8] wip: GameStats
---
src/Typical.Core/GameStats.cs | 61 +++++++++++++++++++++
src/Typical.Tests/Core/GameStatsTests.cs | 70 ++++++++++++++++++++++++
src/Typical.Tests/Typical.Tests.csproj | 1 +
3 files changed, 132 insertions(+)
create mode 100644 src/Typical.Core/GameStats.cs
create mode 100644 src/Typical.Tests/Core/GameStatsTests.cs
diff --git a/src/Typical.Core/GameStats.cs b/src/Typical.Core/GameStats.cs
new file mode 100644
index 0000000..d29603e
--- /dev/null
+++ b/src/Typical.Core/GameStats.cs
@@ -0,0 +1,61 @@
+using System.Diagnostics;
+
+namespace Typical.Core;
+
+public class GameStats(TimeProvider? timeProvider = null)
+{
+ private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
+ private long? _startTimestamp;
+ private long? _endTimestamp;
+
+ public double WordsPerMinute { get; private set; }
+ public double Accuracy { get; private set; }
+ public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue;
+
+ public void Start()
+ {
+ if (!_startTimestamp.HasValue || _endTimestamp.HasValue)
+ {
+ _startTimestamp = _timeProvider.GetTimestamp();
+ _endTimestamp = null;
+ }
+ }
+
+ public void Stop()
+ {
+ if (IsRunning)
+ {
+ _endTimestamp = _timeProvider.GetTimestamp();
+ }
+ }
+
+ public void Update(string targetText, string typedText)
+ {
+ if (!IsRunning || string.IsNullOrEmpty(typedText))
+ {
+ WordsPerMinute = 0;
+ Accuracy = 100;
+ return;
+ }
+
+ long now = _timeProvider.GetTimestamp();
+ var elapsed = _timeProvider.GetElapsedTime(_startTimestamp!.Value, now);
+ double elapsedSeconds = elapsed.TotalSeconds;
+ if (elapsedSeconds <= 0)
+ return;
+
+ var wordCount = typedText.Length / 5.0;
+ WordsPerMinute = wordCount / (elapsedSeconds / 60);
+
+ int correctChars = 0;
+ for (int i = 0; i < typedText.Length; i++)
+ {
+ if (i < targetText.Length && typedText[i] == targetText[i])
+ {
+ correctChars++;
+ }
+ }
+
+ Accuracy = (double)correctChars / typedText.Length * 100.0;
+ }
+}
diff --git a/src/Typical.Tests/Core/GameStatsTests.cs b/src/Typical.Tests/Core/GameStatsTests.cs
new file mode 100644
index 0000000..a1d6633
--- /dev/null
+++ b/src/Typical.Tests/Core/GameStatsTests.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.Extensions.Time.Testing;
+using TUnit;
+using Typical.Core;
+
+namespace Typical.Tests
+{
+ public class GameStatsTests
+ {
+ [Test]
+ public async Task InitialState_ShouldBeDefaults()
+ {
+ var stats = new GameStats();
+
+ await Assert.That(stats.WordsPerMinute).IsEqualTo(0);
+ await Assert.That(stats.Accuracy).IsEqualTo(0);
+ await Assert.That(stats.IsRunning).IsFalse();
+ }
+
+ [Test]
+ public async Task Start_ShouldSetIsRunningTrue()
+ {
+ var fakeTime = new FakeTimeProvider();
+ var stats = new GameStats(fakeTime);
+
+ stats.Start();
+
+ await Assert.That(stats.IsRunning).IsTrue();
+ }
+
+ [Test]
+ public async Task Stop_ShouldSetIsRunningFalse()
+ {
+ var fakeTime = new FakeTimeProvider();
+ var stats = new GameStats(fakeTime);
+
+ stats.Start();
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ stats.Stop();
+
+ await Assert.That(stats.IsRunning).IsFalse();
+ }
+
+ [Test]
+ public async Task Update_ShouldCalculateAccuracy()
+ {
+ var fakeTime = new FakeTimeProvider();
+ var stats = new GameStats(fakeTime);
+
+ stats.Start();
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ stats.Update("hello", "hxllo");
+
+ await Assert.That(stats.Accuracy).IsEqualTo(80);
+ }
+
+ [Test]
+ public async Task Update_ShouldCalculateWordsPerMinute()
+ {
+ var fakeTime = new FakeTimeProvider();
+ var stats = new GameStats(fakeTime);
+
+ stats.Start();
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ stats.Update("hello world", "hello");
+
+ await Assert.That(stats.WordsPerMinute).IsEqualTo(60);
+ }
+ }
+}
diff --git a/src/Typical.Tests/Typical.Tests.csproj b/src/Typical.Tests/Typical.Tests.csproj
index b6d70f4..7f4c01b 100644
--- a/src/Typical.Tests/Typical.Tests.csproj
+++ b/src/Typical.Tests/Typical.Tests.csproj
@@ -6,6 +6,7 @@
net10.0
+
From d72cbf684c0f7a3009f68bf21c27b2624c2d56e4 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Sat, 20 Sep 2025 13:53:15 +0100
Subject: [PATCH 2/8] wip
---
src/Typical.Core/TypicalGame.cs | 18 +++++++++++++++++-
src/Typical/GameRunner.cs | 3 +++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/src/Typical.Core/TypicalGame.cs b/src/Typical.Core/TypicalGame.cs
index 3672df5..5906e4c 100644
--- a/src/Typical.Core/TypicalGame.cs
+++ b/src/Typical.Core/TypicalGame.cs
@@ -5,6 +5,7 @@ namespace Typical.Core;
public class TypicalGame
{
private readonly StringBuilder _userInput;
+ private readonly GameStats _stats;
private readonly ITextProvider _textProvider;
private readonly GameOptions _gameOptions;
private string _targetText = string.Empty;
@@ -17,12 +18,14 @@ public TypicalGame(ITextProvider textProvider, GameOptions gameOptions)
_textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider));
_gameOptions = gameOptions;
_userInput = new StringBuilder();
+ _stats = new GameStats(); // IT CREATES ITS OWN STATS OBJECT
}
public string TargetText => _targetText;
public string UserInput => _userInput.ToString();
public bool IsOver { get; private set; }
public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate;
+ public GameStats Stats => _stats;
public bool ProcessKeyPress(ConsoleKeyInfo key)
{
@@ -38,9 +41,14 @@ public bool ProcessKeyPress(ConsoleKeyInfo key)
}
else if (!char.IsControl(key.KeyChar))
{
+ int currentPos = _userInput.Length;
+ if (currentPos >= _targetText.Length)
+ {
+ return true;
+ }
+
if (_gameOptions.ForbidIncorrectEntries)
{
- int currentPos = _userInput.Length;
if (currentPos < _targetText.Length && key.KeyChar == _targetText[currentPos])
{
_userInput.Append(key.KeyChar);
@@ -50,6 +58,14 @@ public bool ProcessKeyPress(ConsoleKeyInfo key)
{
_userInput.Append(key.KeyChar);
}
+
+ if (
+ !_gameOptions.ForbidIncorrectEntries
+ || (currentPos < _targetText.Length && key.KeyChar == _targetText[currentPos])
+ )
+ {
+ _userInput.Append(key.KeyChar);
+ }
}
if (_userInput.ToString() == _targetText)
diff --git a/src/Typical/GameRunner.cs b/src/Typical/GameRunner.cs
index f4a05ca..c2d9d26 100644
--- a/src/Typical/GameRunner.cs
+++ b/src/Typical/GameRunner.cs
@@ -14,6 +14,7 @@ public class GameRunner
private readonly ThemeManager _theme;
private readonly LayoutFactory _layoutFactory;
private readonly IAnsiConsole _console;
+ private readonly GameStats _stats;
public GameRunner(
TypicalGame engine,
@@ -28,6 +29,7 @@ IAnsiConsole console
_markupGenerator = markupGenerator;
_layoutFactory = layoutFactory;
_console = console;
+ _stats = new GameStats();
}
public void Run()
@@ -58,6 +60,7 @@ public void Run()
if (Console.KeyAvailable)
{
+ _stats.Start();
var key = Console.ReadKey(true);
if (!_engine.ProcessKeyPress(key))
{
From e9c114a4cc7f114158288ec0455bad794c0833f9 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 03:16:39 +0100
Subject: [PATCH 3/8] feat: GameStats info panel
---
src/Typical.Core/CharacterStats.cs | 4 ++
src/Typical.Core/GameStats.cs | 58 ++++++++++-----------
src/Typical.Core/KeystrokeHistory.cs | 77 ++++++++++++++++++++++++++++
src/Typical.Core/KeystrokeLog.cs | 3 ++
src/Typical.Core/KeystrokeType.cs | 8 +++
src/Typical.Core/TypicalGame.cs | 33 ++++++------
src/Typical/GameRunner.cs | 54 +++++++++++++++----
7 files changed, 181 insertions(+), 56 deletions(-)
create mode 100644 src/Typical.Core/CharacterStats.cs
create mode 100644 src/Typical.Core/KeystrokeHistory.cs
create mode 100644 src/Typical.Core/KeystrokeLog.cs
create mode 100644 src/Typical.Core/KeystrokeType.cs
diff --git a/src/Typical.Core/CharacterStats.cs b/src/Typical.Core/CharacterStats.cs
new file mode 100644
index 0000000..31644b8
--- /dev/null
+++ b/src/Typical.Core/CharacterStats.cs
@@ -0,0 +1,4 @@
+namespace Typical.Core;
+
+// A simple record to hold the results of GetCharacterStats
+public record CharacterStats(int Correct, int Incorrect, int Extra);
diff --git a/src/Typical.Core/GameStats.cs b/src/Typical.Core/GameStats.cs
index d29603e..d1e8de1 100644
--- a/src/Typical.Core/GameStats.cs
+++ b/src/Typical.Core/GameStats.cs
@@ -4,21 +4,35 @@ namespace Typical.Core;
public class GameStats(TimeProvider? timeProvider = null)
{
+ private readonly KeystrokeHistory _keystrokeHistory = [];
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
private long? _startTimestamp;
private long? _endTimestamp;
public double WordsPerMinute { get; private set; }
public double Accuracy { get; private set; }
+ public CharacterStats Chars { get; private set; } = new(0, 0, 0);
public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue;
+ public TimeSpan ElapsedTime =>
+ _timeProvider.GetElapsedTime(
+ _startTimestamp ?? 0,
+ _endTimestamp ?? _timeProvider.GetTimestamp()
+ );
public void Start()
{
- if (!_startTimestamp.HasValue || _endTimestamp.HasValue)
- {
- _startTimestamp = _timeProvider.GetTimestamp();
- _endTimestamp = null;
- }
+ Reset();
+ _startTimestamp = _timeProvider.GetTimestamp();
+ }
+
+ public void Reset()
+ {
+ _startTimestamp = null;
+ _endTimestamp = null;
+ _keystrokeHistory.Clear();
+ WordsPerMinute = 0;
+ Accuracy = 100;
+ Chars = new CharacterStats(0, 0, 0);
}
public void Stop()
@@ -29,33 +43,19 @@ public void Stop()
}
}
- public void Update(string targetText, string typedText)
+ public void CalculateStats()
{
- if (!IsRunning || string.IsNullOrEmpty(typedText))
- {
- WordsPerMinute = 0;
- Accuracy = 100;
- return;
- }
-
- long now = _timeProvider.GetTimestamp();
- var elapsed = _timeProvider.GetElapsedTime(_startTimestamp!.Value, now);
- double elapsedSeconds = elapsed.TotalSeconds;
- if (elapsedSeconds <= 0)
- return;
-
- var wordCount = typedText.Length / 5.0;
- WordsPerMinute = wordCount / (elapsedSeconds / 60);
+ WordsPerMinute = _keystrokeHistory.CalculateWpm(ElapsedTime);
+ Accuracy = _keystrokeHistory.CalculateAccuracy();
+ Chars = _keystrokeHistory.GetCharacterStats();
+ }
- int correctChars = 0;
- for (int i = 0; i < typedText.Length; i++)
+ internal void LogKeystroke(char keyChar, KeystrokeType extra)
+ {
+ if (!IsRunning)
{
- if (i < targetText.Length && typedText[i] == targetText[i])
- {
- correctChars++;
- }
+ Start();
}
-
- Accuracy = (double)correctChars / typedText.Length * 100.0;
+ _keystrokeHistory.Add(new KeystrokeLog(keyChar, extra, _timeProvider.GetTimestamp()));
}
}
diff --git a/src/Typical.Core/KeystrokeHistory.cs b/src/Typical.Core/KeystrokeHistory.cs
new file mode 100644
index 0000000..e9b1779
--- /dev/null
+++ b/src/Typical.Core/KeystrokeHistory.cs
@@ -0,0 +1,77 @@
+using System.Collections;
+
+namespace Typical.Core;
+
+public class KeystrokeHistory : IEnumerable
+{
+ private readonly List _logs = new();
+
+ public int Count => _logs.Count;
+ public int CorrectCount => _logs.Count(log => log.Type == KeystrokeType.Correct);
+ public int IncorrectCount => _logs.Count(log => log.Type == KeystrokeType.Incorrect);
+ public int ExtraCount => _logs.Count(log => log.Type == KeystrokeType.Extra);
+
+ private (int Correct, int Incorrect, int Extra) GetCounts()
+ {
+ int correct = 0;
+ int incorrect = 0;
+ int extra = 0;
+
+ foreach (var log in _logs)
+ {
+ switch (log.Type)
+ {
+ case KeystrokeType.Correct:
+ correct++;
+ break;
+ case KeystrokeType.Incorrect:
+ incorrect++;
+ break;
+
+ case KeystrokeType.Extra:
+ extra++;
+ break;
+ }
+ }
+ return (correct, incorrect, extra);
+ }
+
+ public void Add(KeystrokeLog log)
+ {
+ _logs.Add(log);
+ }
+
+ public void Clear()
+ {
+ _logs.Clear();
+ }
+
+ public double CalculateWpm(TimeSpan duration) =>
+ duration.TotalMinutes == 0
+ ? 0
+ : _logs.Count(log => log.Type == KeystrokeType.Correct) / 5.0 / duration.TotalMinutes;
+
+ public double CalculateAccuracy()
+ {
+ if (Count == 0)
+ return 100.0;
+
+ var (correct, incorrect, _) = GetCounts();
+ int totalChars = correct + incorrect;
+ return totalChars == 0 ? 100.0 : (double)correct / totalChars * 100.0;
+ }
+
+ public CharacterStats GetCharacterStats()
+ {
+ var counts = GetCounts();
+ return new CharacterStats(
+ Correct: counts.Correct,
+ Incorrect: counts.Incorrect,
+ Extra: counts.Extra
+ );
+ }
+
+ public IEnumerator GetEnumerator() => _logs.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/Typical.Core/KeystrokeLog.cs b/src/Typical.Core/KeystrokeLog.cs
new file mode 100644
index 0000000..0582453
--- /dev/null
+++ b/src/Typical.Core/KeystrokeLog.cs
@@ -0,0 +1,3 @@
+namespace Typical.Core;
+
+public record struct KeystrokeLog(char Character, KeystrokeType Type, long Timestamp);
diff --git a/src/Typical.Core/KeystrokeType.cs b/src/Typical.Core/KeystrokeType.cs
new file mode 100644
index 0000000..5d24c0f
--- /dev/null
+++ b/src/Typical.Core/KeystrokeType.cs
@@ -0,0 +1,8 @@
+namespace Typical.Core;
+
+public enum KeystrokeType
+{
+ Correct,
+ Incorrect,
+ Extra,
+}
diff --git a/src/Typical.Core/TypicalGame.cs b/src/Typical.Core/TypicalGame.cs
index 5906e4c..ce07469 100644
--- a/src/Typical.Core/TypicalGame.cs
+++ b/src/Typical.Core/TypicalGame.cs
@@ -5,10 +5,8 @@ namespace Typical.Core;
public class TypicalGame
{
private readonly StringBuilder _userInput;
- private readonly GameStats _stats;
private readonly ITextProvider _textProvider;
private readonly GameOptions _gameOptions;
- private string _targetText = string.Empty;
public TypicalGame(ITextProvider textProvider)
: this(textProvider, new GameOptions()) { }
@@ -18,20 +16,22 @@ public TypicalGame(ITextProvider textProvider, GameOptions gameOptions)
_textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider));
_gameOptions = gameOptions;
_userInput = new StringBuilder();
- _stats = new GameStats(); // IT CREATES ITS OWN STATS OBJECT
+ Stats = new GameStats(); // IT CREATES ITS OWN STATS OBJECT
}
- public string TargetText => _targetText;
+ public string TargetText { get; private set; } = string.Empty;
public string UserInput => _userInput.ToString();
public bool IsOver { get; private set; }
+ public bool IsRunning => !IsOver && Stats.IsRunning;
public int TargetFrameDelayMilliseconds => 1000 / _gameOptions.TargetFrameRate;
- public GameStats Stats => _stats;
+ public GameStats Stats { get; }
public bool ProcessKeyPress(ConsoleKeyInfo key)
{
if (key.Key == ConsoleKey.Escape)
{
IsOver = true;
+ Stats.Stop();
return false;
}
@@ -42,43 +42,42 @@ public bool ProcessKeyPress(ConsoleKeyInfo key)
else if (!char.IsControl(key.KeyChar))
{
int currentPos = _userInput.Length;
- if (currentPos >= _targetText.Length)
+ if (currentPos >= TargetText.Length)
{
- return true;
+ Stats.LogKeystroke(key.KeyChar, KeystrokeType.Extra);
}
-
- if (_gameOptions.ForbidIncorrectEntries)
+ else if (key.KeyChar == TargetText[currentPos])
{
- if (currentPos < _targetText.Length && key.KeyChar == _targetText[currentPos])
- {
- _userInput.Append(key.KeyChar);
- }
+ Stats.LogKeystroke(key.KeyChar, KeystrokeType.Correct);
}
else
{
- _userInput.Append(key.KeyChar);
+ Stats.LogKeystroke(key.KeyChar, KeystrokeType.Incorrect);
}
if (
!_gameOptions.ForbidIncorrectEntries
- || (currentPos < _targetText.Length && key.KeyChar == _targetText[currentPos])
+ || (currentPos < TargetText.Length && key.KeyChar == TargetText[currentPos])
)
{
_userInput.Append(key.KeyChar);
}
}
- if (_userInput.ToString() == _targetText)
+ if (_userInput.ToString() == TargetText)
{
IsOver = true;
+ Stats.Stop();
}
+ Stats.CalculateStats();
return true;
}
public async Task StartNewGame()
{
- _targetText = await _textProvider.GetTextAsync();
+ TargetText = await _textProvider.GetTextAsync();
+ Stats.Start();
_userInput.Clear();
IsOver = false;
}
diff --git a/src/Typical/GameRunner.cs b/src/Typical/GameRunner.cs
index c2d9d26..6f9326b 100644
--- a/src/Typical/GameRunner.cs
+++ b/src/Typical/GameRunner.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Spectre.Console;
using Spectre.Console.Rendering;
using Typical.Core;
@@ -35,13 +36,14 @@ IAnsiConsole console
public void Run()
{
var layout = _layoutFactory.Build(LayoutName.Dashboard);
-
+ const int statsUpdateIntervalMs = 2000; // Update stats every 2 seconds
+ var statsTimer = Stopwatch.StartNew();
_console
.Live(layout)
.Start(ctx =>
{
- var typingArea = layout[LayoutSection.TypingArea.Value];
- typingArea.Update(CreateTypingArea());
+ layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
+ layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
ctx.Refresh();
int lastHeight = Console.WindowHeight;
@@ -49,13 +51,15 @@ public void Run()
while (true)
{
- bool needsRefresh = false;
+ bool needsTypingRefresh = false;
+ bool needsStatsRefresh = false;
if (Console.WindowWidth != lastWidth || Console.WindowHeight != lastHeight)
{
lastWidth = Console.WindowWidth;
lastHeight = Console.WindowHeight;
- needsRefresh = true;
+ needsTypingRefresh = true;
+ needsStatsRefresh = true;
}
if (Console.KeyAvailable)
@@ -63,20 +67,36 @@ public void Run()
_stats.Start();
var key = Console.ReadKey(true);
if (!_engine.ProcessKeyPress(key))
- {
break;
- }
- needsRefresh = true;
+
+ needsTypingRefresh = true;
+ }
+
+ if (_engine.IsRunning && statsTimer.ElapsedMilliseconds > statsUpdateIntervalMs)
+ {
+ needsStatsRefresh = true;
+ statsTimer.Restart(); // Reset the timer
}
- if (needsRefresh)
+ if (needsTypingRefresh)
+ {
+ layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
+ }
+ if (needsStatsRefresh)
+ {
+ layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
+ }
+
+ if (needsTypingRefresh || needsStatsRefresh)
{
- typingArea.Update(CreateTypingArea());
ctx.Refresh();
}
if (_engine.IsOver)
{
+ layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
+ layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
+ ctx.Refresh();
Thread.Sleep(500);
break;
}
@@ -88,6 +108,20 @@ public void Run()
DisplaySummary();
}
+ private IRenderable CreateGameInfoArea()
+ {
+ var grid = new Grid();
+ grid.AddColumns([new GridColumn(), new GridColumn()]);
+ grid.AddRow("WPM:", $"{_engine.Stats.WordsPerMinute:F1}");
+ grid.AddRow("Accuracy:", $"{_engine.Stats.Accuracy:F1}%");
+ grid.AddRow("Correct Chars:", $"{_engine.Stats.Chars.Correct}");
+ grid.AddRow("Incorrect Chars:", $"{_engine.Stats.Chars.Incorrect}");
+ grid.AddRow("Extra Chars:", $"{_engine.Stats.Chars.Extra}");
+ var panel = new Panel(grid);
+ var applied = _theme.Apply(panel, LayoutSection.GameInfo);
+ return applied;
+ }
+
private IRenderable CreateTypingArea()
{
var markup = _markupGenerator.BuildMarkupOptimized(_engine.TargetText, _engine.UserInput);
From 3a4a28459aaf63b3de779e8d2ae64fb3d321a29a Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 04:04:02 +0100
Subject: [PATCH 4/8] test: fix to work with updated GameStats
---
src/Typical.Tests/Core/GameStatsTests.cs | 31 +++++++++++++++++++++---
1 file changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/Typical.Tests/Core/GameStatsTests.cs b/src/Typical.Tests/Core/GameStatsTests.cs
index a1d6633..d75b840 100644
--- a/src/Typical.Tests/Core/GameStatsTests.cs
+++ b/src/Typical.Tests/Core/GameStatsTests.cs
@@ -13,7 +13,7 @@ public async Task InitialState_ShouldBeDefaults()
var stats = new GameStats();
await Assert.That(stats.WordsPerMinute).IsEqualTo(0);
- await Assert.That(stats.Accuracy).IsEqualTo(0);
+ await Assert.That(stats.Accuracy).IsEqualTo(100);
await Assert.That(stats.IsRunning).IsFalse();
}
@@ -49,8 +49,20 @@ public async Task Update_ShouldCalculateAccuracy()
stats.Start();
fakeTime.Advance(TimeSpan.FromSeconds(1));
- stats.Update("hello", "hxllo");
+ string target = "hello";
+ string input = "hxllo"; // 1 incorrect out of 5
+ foreach (var (c, i) in target.Zip(input))
+ {
+ if (c == i)
+ {
+ stats.LogKeystroke(c, KeystrokeType.Correct);
+ }
+ else
+ {
+ stats.LogKeystroke(i, KeystrokeType.Incorrect);
+ }
+ }
await Assert.That(stats.Accuracy).IsEqualTo(80);
}
@@ -62,7 +74,20 @@ public async Task Update_ShouldCalculateWordsPerMinute()
stats.Start();
fakeTime.Advance(TimeSpan.FromSeconds(1));
- stats.Update("hello world", "hello");
+ string target = "hello world";
+ string input = "hello";
+
+ foreach (var (c, i) in target.Zip(input))
+ {
+ if (c == i)
+ {
+ stats.LogKeystroke(c, KeystrokeType.Correct);
+ }
+ else
+ {
+ stats.LogKeystroke(i, KeystrokeType.Incorrect);
+ }
+ }
await Assert.That(stats.WordsPerMinute).IsEqualTo(60);
}
From 30e0fe1184c97dbf52e233efff9a14be82511004 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 04:48:04 +0100
Subject: [PATCH 5/8] refactor ThemeManager.Apply
---
src/Typical.Core/GameStats.cs | 52 +++++++++++++----
src/Typical.Core/Typical.Core.csproj | 3 +
src/Typical.Core/TypicalGame.cs | 1 -
src/Typical/GameRunner.cs | 16 +++---
src/Typical/Program.cs | 2 +-
src/Typical/TUI/Runtime/LayoutFactory.cs | 2 -
src/Typical/TUI/Settings/ElementStyle.cs | 1 +
src/Typical/TUI/Settings/ThemeManager.cs | 72 +++++++++++++++---------
src/Typical/config.json | 13 +++--
9 files changed, 108 insertions(+), 54 deletions(-)
diff --git a/src/Typical.Core/GameStats.cs b/src/Typical.Core/GameStats.cs
index d1e8de1..1795a91 100644
--- a/src/Typical.Core/GameStats.cs
+++ b/src/Typical.Core/GameStats.cs
@@ -8,10 +8,39 @@ public class GameStats(TimeProvider? timeProvider = null)
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
private long? _startTimestamp;
private long? _endTimestamp;
+ private bool _statsAreDirty = true; // Start dirty
+ private double _cachedWpm;
+ private double _cachedAccuracy;
+ private CharacterStats _cachedChars = new(0, 0, 0);
+ public double WordsPerMinute
+ {
+ get
+ {
+ if (_statsAreDirty)
+ RecalculateAllStats();
+ return _cachedWpm;
+ }
+ }
+
+ public double Accuracy
+ {
+ get
+ {
+ if (_statsAreDirty)
+ RecalculateAllStats();
+ return _cachedAccuracy;
+ }
+ }
- public double WordsPerMinute { get; private set; }
- public double Accuracy { get; private set; }
- public CharacterStats Chars { get; private set; } = new(0, 0, 0);
+ public CharacterStats Chars
+ {
+ get
+ {
+ if (_statsAreDirty)
+ RecalculateAllStats();
+ return _cachedChars;
+ }
+ }
public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue;
public TimeSpan ElapsedTime =>
_timeProvider.GetElapsedTime(
@@ -30,9 +59,9 @@ public void Reset()
_startTimestamp = null;
_endTimestamp = null;
_keystrokeHistory.Clear();
- WordsPerMinute = 0;
- Accuracy = 100;
- Chars = new CharacterStats(0, 0, 0);
+ _cachedWpm = 0;
+ _cachedAccuracy = 100;
+ _cachedChars = new CharacterStats(0, 0, 0);
}
public void Stop()
@@ -43,11 +72,13 @@ public void Stop()
}
}
- public void CalculateStats()
+ private void RecalculateAllStats()
{
- WordsPerMinute = _keystrokeHistory.CalculateWpm(ElapsedTime);
- Accuracy = _keystrokeHistory.CalculateAccuracy();
- Chars = _keystrokeHistory.GetCharacterStats();
+ _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime);
+ _cachedAccuracy = _keystrokeHistory.CalculateAccuracy();
+ _cachedChars = _keystrokeHistory.GetCharacterStats();
+
+ _statsAreDirty = false; // The stats are now fresh
}
internal void LogKeystroke(char keyChar, KeystrokeType extra)
@@ -57,5 +88,6 @@ internal void LogKeystroke(char keyChar, KeystrokeType extra)
Start();
}
_keystrokeHistory.Add(new KeystrokeLog(keyChar, extra, _timeProvider.GetTimestamp()));
+ _statsAreDirty = true; // Mark stats as dirty
}
}
diff --git a/src/Typical.Core/Typical.Core.csproj b/src/Typical.Core/Typical.Core.csproj
index 31c645f..3686269 100644
--- a/src/Typical.Core/Typical.Core.csproj
+++ b/src/Typical.Core/Typical.Core.csproj
@@ -7,4 +7,7 @@
embedded
preview
+
+
+
diff --git a/src/Typical.Core/TypicalGame.cs b/src/Typical.Core/TypicalGame.cs
index ce07469..894f0fb 100644
--- a/src/Typical.Core/TypicalGame.cs
+++ b/src/Typical.Core/TypicalGame.cs
@@ -69,7 +69,6 @@ public bool ProcessKeyPress(ConsoleKeyInfo key)
IsOver = true;
Stats.Stop();
}
- Stats.CalculateStats();
return true;
}
diff --git a/src/Typical/GameRunner.cs b/src/Typical/GameRunner.cs
index 6f9326b..307f7a7 100644
--- a/src/Typical/GameRunner.cs
+++ b/src/Typical/GameRunner.cs
@@ -36,7 +36,7 @@ IAnsiConsole console
public void Run()
{
var layout = _layoutFactory.Build(LayoutName.Dashboard);
- const int statsUpdateIntervalMs = 2000; // Update stats every 2 seconds
+ const int statsUpdateIntervalMs = 1000; // Update stats every 2 seconds
var statsTimer = Stopwatch.StartNew();
_console
.Live(layout)
@@ -44,6 +44,7 @@ public void Run()
{
layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
+ layout[LayoutSection.Header.Value].Update(CreateHeader());
ctx.Refresh();
int lastHeight = Console.WindowHeight;
@@ -117,18 +118,19 @@ private IRenderable CreateGameInfoArea()
grid.AddRow("Correct Chars:", $"{_engine.Stats.Chars.Correct}");
grid.AddRow("Incorrect Chars:", $"{_engine.Stats.Chars.Incorrect}");
grid.AddRow("Extra Chars:", $"{_engine.Stats.Chars.Extra}");
- var panel = new Panel(grid);
- var applied = _theme.Apply(panel, LayoutSection.GameInfo);
- return applied;
+ grid.AddRow("Elapsed:", $"{_engine.Stats.ElapsedTime:mm\\:ss}");
+ return _theme.Apply(grid, LayoutSection.GameInfo);
}
private IRenderable CreateTypingArea()
{
var markup = _markupGenerator.BuildMarkupOptimized(_engine.TargetText, _engine.UserInput);
- var panel = new Panel(markup);
+ return _theme.Apply(markup, LayoutSection.TypingArea);
+ }
- IRenderable applied = _theme.Apply(panel, LayoutSection.TypingArea);
- return applied;
+ private IRenderable CreateHeader()
+ {
+ return _theme.Apply(new Markup("Typical - A Typing Tutor"), LayoutSection.Header);
}
private Action DisplaySummary() =>
diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs
index 7eca6dd..b406667 100644
--- a/src/Typical/Program.cs
+++ b/src/Typical/Program.cs
@@ -30,7 +30,7 @@
var themeManager = new ThemeManager(appSettings.Themes.ToRuntimeThemes(), defaultTheme: "Default");
var layoutFactory = new LayoutFactory(appSettings.Layouts.ToRuntimeLayouts());
-ITextProvider textProvider = new StaticTextProvider("[[Helloooo]]");
+ITextProvider textProvider = new StaticTextProvider("The quick brown fox jumps over the lazy dog.");
var game = new TypicalGame(textProvider);
await game.StartNewGame();
diff --git a/src/Typical/TUI/Runtime/LayoutFactory.cs b/src/Typical/TUI/Runtime/LayoutFactory.cs
index ec5ac26..670324c 100644
--- a/src/Typical/TUI/Runtime/LayoutFactory.cs
+++ b/src/Typical/TUI/Runtime/LayoutFactory.cs
@@ -1,6 +1,4 @@
-using System.Diagnostics.Tracing;
using Spectre.Console;
-using Spectre.Console.Rendering;
using Typical.TUI.Settings;
namespace Typical.TUI.Runtime;
diff --git a/src/Typical/TUI/Settings/ElementStyle.cs b/src/Typical/TUI/Settings/ElementStyle.cs
index 173f20f..ada2b6a 100644
--- a/src/Typical/TUI/Settings/ElementStyle.cs
+++ b/src/Typical/TUI/Settings/ElementStyle.cs
@@ -5,4 +5,5 @@ public class ElementStyle
public BorderStyleSettings? BorderStyle { get; set; }
public PanelHeaderSettings? PanelHeader { get; set; }
public AlignmentSettings? Alignment { get; set; }
+ public bool WrapInPanel { get; internal set; } = true;
}
diff --git a/src/Typical/TUI/Settings/ThemeManager.cs b/src/Typical/TUI/Settings/ThemeManager.cs
index 0bdfbc9..01b1216 100644
--- a/src/Typical/TUI/Settings/ThemeManager.cs
+++ b/src/Typical/TUI/Settings/ThemeManager.cs
@@ -22,49 +22,65 @@ public ThemeManager(Dictionary themes, string? defaultThem
_activeTheme = _themes[ActiveThemeName];
}
- public IRenderable Apply(IRenderable renderable, LayoutSection layoutName)
+ public IRenderable Apply(T renderable, LayoutSection layoutName)
+ where T : IRenderable
{
if (!_activeTheme.TryGetValue(layoutName, out var style))
{
_activeTheme.TryGetValue(LayoutSection.Default, out style);
}
- if (style is null)
- return renderable;
- if (renderable is Panel panel)
- {
- if (style.BorderStyle is not null)
- {
- var foreground = style.BorderStyle.ForegroundColor is not null
- ? ParseColor(style.BorderStyle.ForegroundColor)
- : Color.Default;
- Enum.TryParse(style.BorderStyle.Decoration, true, out var decoration);
+ style ??= new ElementStyle();
- panel.BorderStyle = new Style(foreground: foreground, decoration: decoration);
- }
+ if (layoutName == LayoutSection.Header)
+ style.WrapInPanel = false;
- if (style.PanelHeader?.Text is not null)
+ if (!style.WrapInPanel)
+ return renderable;
+
+ Panel finalPanel;
+
+ if (renderable is Panel existingPanel)
+ {
+ finalPanel = existingPanel;
+ }
+ else
+ {
+ IRenderable content = renderable;
+ if (style.Alignment is not null)
{
- panel.Header = new PanelHeader(style.PanelHeader.Text);
+ var verticalAlign = Enum.Parse(
+ style.Alignment.Vertical.ToString(),
+ true
+ );
+
+ content = Enum.Parse(style.Alignment.Horizontal.ToString(), true) switch
+ {
+ Justify.Left => Align.Left(content, verticalAlign),
+ Justify.Center => Align.Center(content, verticalAlign),
+ Justify.Right => Align.Right(content, verticalAlign),
+ _ => renderable,
+ };
}
+ finalPanel = new Panel(content);
}
- if (style.Alignment is not null)
+ if (style.BorderStyle is not null)
{
- var verticalAlign = Enum.Parse(
- style.Alignment.Vertical.ToString(),
- true
- );
+ var foreground = style.BorderStyle.ForegroundColor is not null
+ ? ParseColor(style.BorderStyle.ForegroundColor)
+ : Color.Default;
- renderable = Enum.Parse(style.Alignment.Horizontal.ToString(), true) switch
- {
- Justify.Left => Align.Left(renderable, verticalAlign),
- Justify.Center => Align.Center(renderable, verticalAlign),
- Justify.Right => Align.Right(renderable, verticalAlign),
- _ => renderable,
- };
+ Enum.TryParse(style.BorderStyle.Decoration, true, out var decoration);
+
+ finalPanel.BorderStyle = new Style(foreground: foreground, decoration: decoration);
+ }
+
+ if (style.PanelHeader?.Text is not null)
+ {
+ finalPanel.Header = new PanelHeader(style.PanelHeader.Text);
}
- return renderable;
+ return finalPanel.Expand();
}
private static Color? ParseColor(string stringColor)
diff --git a/src/Typical/config.json b/src/Typical/config.json
index 425730a..7b07029 100644
--- a/src/Typical/config.json
+++ b/src/Typical/config.json
@@ -50,14 +50,14 @@
"TypingArea": {
"BorderStyle": {
"ForegroundColor": "Yellow",
- "Decoration": "None",
+ "Decoration": "None"
},
"PanelHeader": {
- "Text": "[yellow]test Area[/]"
+ "Text": "[yellow]Type here[/]"
},
"Alignment": {
"Vertical": "Middle",
- "Horizontal": "Right"
+ "Horizontal": "Center"
}
},
"Header": {
@@ -70,15 +70,18 @@
},
"GameInfo": {
"BorderStyle": {
- "ForegroundColor": "Grey"
+ "ForegroundColor": "Blue"
},
"PanelHeader": {
"Text": "Stats"
+ },
+ "Alignment": {
+ "Vertical": "Middle"
}
},
"Default": {
"BorderStyle": {
- "ForegroundColor": "Grey50"
+ "ForegroundColor": "Gray50"
}
}
}
From aaa8abd6478eeaaa0b4e2a209dd6c3b4918c0c35 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 04:56:39 +0100
Subject: [PATCH 6/8] fix: remove unused GameStats
---
src/Typical/GameRunner.cs | 21 +++++++++++----------
1 file changed, 11 insertions(+), 10 deletions(-)
diff --git a/src/Typical/GameRunner.cs b/src/Typical/GameRunner.cs
index 307f7a7..3dbfc61 100644
--- a/src/Typical/GameRunner.cs
+++ b/src/Typical/GameRunner.cs
@@ -15,7 +15,6 @@ public class GameRunner
private readonly ThemeManager _theme;
private readonly LayoutFactory _layoutFactory;
private readonly IAnsiConsole _console;
- private readonly GameStats _stats;
public GameRunner(
TypicalGame engine,
@@ -30,7 +29,6 @@ IAnsiConsole console
_markupGenerator = markupGenerator;
_layoutFactory = layoutFactory;
_console = console;
- _stats = new GameStats();
}
public void Run()
@@ -42,9 +40,13 @@ public void Run()
.Live(layout)
.Start(ctx =>
{
- layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
- layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
- layout[LayoutSection.Header.Value].Update(CreateHeader());
+ var typingArea = layout[LayoutSection.TypingArea.Value];
+ var statsArea = layout[LayoutSection.GameInfo.Value];
+ var headerArea = layout[LayoutSection.Header.Value];
+ typingArea.Update(CreateTypingArea());
+ statsArea.Update(CreateGameInfoArea());
+ headerArea.Update(CreateHeader());
+
ctx.Refresh();
int lastHeight = Console.WindowHeight;
@@ -65,7 +67,6 @@ public void Run()
if (Console.KeyAvailable)
{
- _stats.Start();
var key = Console.ReadKey(true);
if (!_engine.ProcessKeyPress(key))
break;
@@ -81,11 +82,11 @@ public void Run()
if (needsTypingRefresh)
{
- layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
+ typingArea.Update(CreateTypingArea());
}
if (needsStatsRefresh)
{
- layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
+ statsArea.Update(CreateGameInfoArea());
}
if (needsTypingRefresh || needsStatsRefresh)
@@ -95,8 +96,8 @@ public void Run()
if (_engine.IsOver)
{
- layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea());
- layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea());
+ typingArea.Update(CreateTypingArea());
+ statsArea.Update(CreateGameInfoArea());
ctx.Refresh();
Thread.Sleep(500);
break;
From e7f808f737db997effad8776d2da6a1b4ec1b90f Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 05:15:50 +0100
Subject: [PATCH 7/8] feat: add sample quote
---
src/Typical/Program.cs | 9 ++++++++-
src/Typical/Typical.csproj | 3 +++
src/Typical/quote.txt | 1 +
3 files changed, 12 insertions(+), 1 deletion(-)
create mode 100644 src/Typical/quote.txt
diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs
index b406667..0a69bce 100644
--- a/src/Typical/Program.cs
+++ b/src/Typical/Program.cs
@@ -30,7 +30,14 @@
var themeManager = new ThemeManager(appSettings.Themes.ToRuntimeThemes(), defaultTheme: "Default");
var layoutFactory = new LayoutFactory(appSettings.Layouts.ToRuntimeLayouts());
-ITextProvider textProvider = new StaticTextProvider("The quick brown fox jumps over the lazy dog.");
+
+string quotePath = Path.Combine(AppContext.BaseDirectory, "quote.txt");
+
+string text = File.Exists(quotePath)
+ ? await File.ReadAllTextAsync(quotePath)
+ : "The quick brown fox jumps over the lazy dog.";
+
+ITextProvider textProvider = new StaticTextProvider(text);
var game = new TypicalGame(textProvider);
await game.StartNewGame();
diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj
index a6d2c79..7756b95 100644
--- a/src/Typical/Typical.csproj
+++ b/src/Typical/Typical.csproj
@@ -44,6 +44,9 @@
PreserveNewest
+
+ PreserveNewest
+
diff --git a/src/Typical/quote.txt b/src/Typical/quote.txt
new file mode 100644
index 0000000..83db302
--- /dev/null
+++ b/src/Typical/quote.txt
@@ -0,0 +1 @@
+What an astonishing thing a book is. It's a flat object made from a tree with flexible parts on which are imprinted lots of funny dark squiggles. But one glance at it and you're inside the mind of another person, maybe somebody dead for thousands of years. Across the millennia, an author is speaking clearly and silently inside your head, directly to you. Writing is perhaps the greatest of human inventions, binding together people who never knew each other, citizens of distant epochs. Books break the shackles of time. A book is proof that humans are capable of working magic.
\ No newline at end of file
From 32b28e26f02918d4a1a60a29535b6dd83325d666 Mon Sep 17 00:00:00 2001
From: jamesshenry <79054685+henry-js@users.noreply.github.com>
Date: Mon, 22 Sep 2025 05:16:38 +0100
Subject: [PATCH 8/8] docs: remove
---
README.md | 46 ----------------------------------------------
1 file changed, 46 deletions(-)
diff --git a/README.md b/README.md
index fc72f10..e69de29 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +0,0 @@
-# Docs
-
-```mermaid
-graph TD
- subgraph "Typical.Console (Presentation Layer)"
- direction LR
- A[Program.cs] --> B[GameRunner/UI Loop];
- B --> C[MarkupGenerator];
- C --> D[Spectre.Console];
- B --> E[Typical.Core];
- end
-
- subgraph "Typical.Core (Business Logic Layer)"
- direction LR
- E[TyperGame Engine] --> H(ITextProvider Interface);
- end
-
- subgraph "Typical.DataAccess (Data Access Layer)"
- direction LR
- I[FileTextProvider] --> H;
- end
-```
-
----
-
-```mermaid
-sequenceDiagram
- participant Program as Program.cs
- participant Engine as TyperGame (Core)
- participant Runner as ConsoleGameRunner
- participant Spectre as Spectre.Console
-
- Program->>Engine: new TyperGame(provider, options)
- Program->>Engine: StartNewGame()
- Program->>Runner: new ConsoleGameRunner(Engine)
- Program->>Runner: Run()
- Runner->>Spectre: Live(...).Start()
- loop Game Loop
- Runner->>Engine: ProcessKeystroke()
- Runner->>Engine: GetStats()
- Runner->>Spectre: ctx.Refresh()
- end
- Spectre-->>Runner: Loop Ends
- Runner-->>Program: Run() Completes
- Program->>Spectre: AnsiConsole.MarkupLine("Game Over")
-```