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") -``` 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 new file mode 100644 index 0000000..1795a91 --- /dev/null +++ b/src/Typical.Core/GameStats.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; + +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; + 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 CharacterStats Chars + { + get + { + if (_statsAreDirty) + RecalculateAllStats(); + return _cachedChars; + } + } + public bool IsRunning => _startTimestamp.HasValue && !_endTimestamp.HasValue; + public TimeSpan ElapsedTime => + _timeProvider.GetElapsedTime( + _startTimestamp ?? 0, + _endTimestamp ?? _timeProvider.GetTimestamp() + ); + + public void Start() + { + Reset(); + _startTimestamp = _timeProvider.GetTimestamp(); + } + + public void Reset() + { + _startTimestamp = null; + _endTimestamp = null; + _keystrokeHistory.Clear(); + _cachedWpm = 0; + _cachedAccuracy = 100; + _cachedChars = new CharacterStats(0, 0, 0); + } + + public void Stop() + { + if (IsRunning) + { + _endTimestamp = _timeProvider.GetTimestamp(); + } + } + + private void RecalculateAllStats() + { + _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime); + _cachedAccuracy = _keystrokeHistory.CalculateAccuracy(); + _cachedChars = _keystrokeHistory.GetCharacterStats(); + + _statsAreDirty = false; // The stats are now fresh + } + + internal void LogKeystroke(char keyChar, KeystrokeType extra) + { + if (!IsRunning) + { + Start(); + } + _keystrokeHistory.Add(new KeystrokeLog(keyChar, extra, _timeProvider.GetTimestamp())); + _statsAreDirty = true; // Mark stats as dirty + } +} 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/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 3672df5..894f0fb 100644 --- a/src/Typical.Core/TypicalGame.cs +++ b/src/Typical.Core/TypicalGame.cs @@ -7,7 +7,6 @@ public class TypicalGame private readonly StringBuilder _userInput; private readonly ITextProvider _textProvider; private readonly GameOptions _gameOptions; - private string _targetText = string.Empty; public TypicalGame(ITextProvider textProvider) : this(textProvider, new GameOptions()) { } @@ -17,18 +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 } - 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 { get; } public bool ProcessKeyPress(ConsoleKeyInfo key) { if (key.Key == ConsoleKey.Escape) { IsOver = true; + Stats.Stop(); return false; } @@ -38,23 +41,33 @@ public bool ProcessKeyPress(ConsoleKeyInfo key) } else if (!char.IsControl(key.KeyChar)) { - if (_gameOptions.ForbidIncorrectEntries) + int currentPos = _userInput.Length; + if (currentPos >= TargetText.Length) { - int currentPos = _userInput.Length; - if (currentPos < _targetText.Length && key.KeyChar == _targetText[currentPos]) - { - _userInput.Append(key.KeyChar); - } + Stats.LogKeystroke(key.KeyChar, KeystrokeType.Extra); + } + else if (key.KeyChar == TargetText[currentPos]) + { + Stats.LogKeystroke(key.KeyChar, KeystrokeType.Correct); } else + { + Stats.LogKeystroke(key.KeyChar, KeystrokeType.Incorrect); + } + + if ( + !_gameOptions.ForbidIncorrectEntries + || (currentPos < TargetText.Length && key.KeyChar == TargetText[currentPos]) + ) { _userInput.Append(key.KeyChar); } } - if (_userInput.ToString() == _targetText) + if (_userInput.ToString() == TargetText) { IsOver = true; + Stats.Stop(); } return true; @@ -62,7 +75,8 @@ public bool ProcessKeyPress(ConsoleKeyInfo key) public async Task StartNewGame() { - _targetText = await _textProvider.GetTextAsync(); + TargetText = await _textProvider.GetTextAsync(); + Stats.Start(); _userInput.Clear(); IsOver = false; } diff --git a/src/Typical.Tests/Core/GameStatsTests.cs b/src/Typical.Tests/Core/GameStatsTests.cs new file mode 100644 index 0000000..d75b840 --- /dev/null +++ b/src/Typical.Tests/Core/GameStatsTests.cs @@ -0,0 +1,95 @@ +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(100); + 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)); + 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); + } + + [Test] + public async Task Update_ShouldCalculateWordsPerMinute() + { + var fakeTime = new FakeTimeProvider(); + var stats = new GameStats(fakeTime); + + stats.Start(); + fakeTime.Advance(TimeSpan.FromSeconds(1)); + 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); + } + } +} 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 + diff --git a/src/Typical/GameRunner.cs b/src/Typical/GameRunner.cs index f4a05ca..3dbfc61 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; @@ -33,13 +34,19 @@ IAnsiConsole console public void Run() { var layout = _layoutFactory.Build(LayoutName.Dashboard); - + const int statsUpdateIntervalMs = 1000; // Update stats every 2 seconds + var statsTimer = Stopwatch.StartNew(); _console .Live(layout) .Start(ctx => { 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; @@ -47,33 +54,51 @@ 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) { var key = Console.ReadKey(true); if (!_engine.ProcessKeyPress(key)) - { break; - } - needsRefresh = true; + + needsTypingRefresh = true; } - if (needsRefresh) + if (_engine.IsRunning && statsTimer.ElapsedMilliseconds > statsUpdateIntervalMs) + { + needsStatsRefresh = true; + statsTimer.Restart(); // Reset the timer + } + + if (needsTypingRefresh) { typingArea.Update(CreateTypingArea()); + } + if (needsStatsRefresh) + { + statsArea.Update(CreateGameInfoArea()); + } + + if (needsTypingRefresh || needsStatsRefresh) + { ctx.Refresh(); } if (_engine.IsOver) { + typingArea.Update(CreateTypingArea()); + statsArea.Update(CreateGameInfoArea()); + ctx.Refresh(); Thread.Sleep(500); break; } @@ -85,13 +110,28 @@ 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}"); + 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..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("[[Helloooo]]"); + +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/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/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/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" } } } 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