diff --git a/.github/codebase.ps1 b/.github/codebase.ps1 new file mode 100644 index 0000000..92290a5 --- /dev/null +++ b/.github/codebase.ps1 @@ -0,0 +1,30 @@ +$repoRoot = Resolve-Path "$PSScriptRoot/.." + +Write-Host $repoRoot + +$sourceDirectory = Join-Path $repoRoot 'src' + +Write-Host $sourceDirectory + +$outputFile = "$repoRoot/.github/instructions/codebase.txt" + +Write-Host $outputFile + +$directoryTree = Get-ChildItem -Directory -Path $sourceDirectory -Recurse | ForEach-Object { + $indent = ' ' * ($_.FullName.Split('\').Length - $sourceDirectory.Split('\').Length) + "$indent- $($_.Name)" +} | Out-String + +$contextBlock = "$directoryTree`n# --- Start of Code Files ---`n`n" + +Set-Content -Path $outputFile -Value $contextBlock + +$csharpFiles = Get-ChildItem -Path $sourceDirectory -Recurse -Include '*.cs' + +foreach ($file in $csharpFiles) { + $filePathHeader = "`n// File: $($file.FullName.Substring($PWD.Path.Length + 1))`n`n" + $fileContent = Get-Content -Path $file.FullName | Out-String + Add-Content -Path $outputFile -Value ($filePathHeader + $fileContent) +} + +Write-Host "C# codebase with contextual messages has been concatenated into $outputFile" \ No newline at end of file diff --git a/.github/instructions/codebase.md b/.github/instructions/codebase.md new file mode 100644 index 0000000..8284720 --- /dev/null +++ b/.github/instructions/codebase.md @@ -0,0 +1,2482 @@ +# Codebase Analysis for Typical + +## Project Overview + +This is a console application similar to monkeytype, aimed at helping users improve their typing skills. + +## Analysis Goals + +Please analyze the following codebase for potential bugs, performance improvements, and adherence to modern C# best practices. +Your current task is to improve the logging system of the application. Focus on a specific area each time and plan your steps. Never run an external command unless requested + +## Directory Structure + +Below is a summary of the directory structure to provide context for the file organization. + - Typical + - Typical.Core + - Typical.Tests + - TUI\ + - Enums + - Runtime + - Settings + - Events + - Statistics + - Text + - Core + - TUI + +## --- Start of Code Files --- + +- Typical +- Typical.Core +- Typical.Tests + - Commands + - Configuration + - Data + - Filters + - Logging + - Properties + - Services + - TUI + - Enums + - Runtime + - Settings + - Views + - Events + - Statistics + - Text + - Core + - TUI + +# --- Start of Code Files --- + +// File: src\Typical\Logging\AppLogs.cs + +using Microsoft.Extensions.Logging; +using Typical; +using Typical.TUI; + +public static partial class AppLogs +{ + // Define a log message with ID, level, template + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Application starting...")] + public static partial void ApplicationStarting(ILogger logger); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "No commands specified, starting interactive AppShell." + )] + public static partial void NoCommandsInteractive(ILogger logger); + + // Example with parameters + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Failed to process user {UserId}" + )] + public static partial void FailedToProcessUser(ILogger logger, int userId); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "Starting direct game with Mode: {Mode}, Duration: {Duration}" + )] + public static partial void StartingGame(ILogger logger, string mode, int duration); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = ("Application shutting down.") + )] + public static partial void ApplicationStopping(ILogger logger); +} + +// File: src\Typical\Logging\SourceClassEnricher.cs + +using Serilog.Core; +using Serilog.Events; + +public class SourceClassEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if ( + logEvent.Properties.TryGetValue("SourceContext", out var value) + && value is ScalarValue sv + && sv.Value is string fullName + ) + { + var shortName = fullName.Split('.').Last(); + var property = propertyFactory.CreateProperty("SourceClass", shortName); + logEvent.AddOrUpdateProperty(property); + } + } +} + +// File: src\Typical\Services\ServiceExtensions.cs + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Formatting.Display; +using Serilog.Sinks.SystemConsole.Themes; +using Spectre.Console; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Text; +using Typical.TUI; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; +using Typical.TUI.Views; + +namespace Typical.Services; + +public static class ServiceExtensions +{ + public static IConfiguration CreateConfiguration() => + new ConfigurationBuilder().AddJsonFile("./config.json", false).Build(); + + public static void ConfigureSerilog(this ILoggingBuilder builder) + { + const string outputTemplate = + "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceClass}) {Message:lj}{NewLine}{Exception}"; + builder.AddSerilog( + new LoggerConfiguration() + .WriteTo.File( + formatter: new MessageTemplateTextFormatter(outputTemplate), + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "app-.log"), + shared: true, + rollingInterval: RollingInterval.Day + ) + .Enrich.WithProperty("ApplicationName", "") + .Enrich.With() + .WriteTo.Console(outputTemplate: outputTemplate, theme: AnsiConsoleTheme.Sixteen) + .CreateLogger() + ); + } + + public static IServiceCollection RegisterAppServices(this IServiceCollection services) + { + var configuration = CreateConfiguration(); + var appSettings = configuration.Get()!; + services.AddLogging(ConfigureSerilog); + + services.AddSingleton(configuration); + + services.AddSingleton(); + services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(_ => new ThemeManager( + appSettings.Themes.ToRuntimeThemes(), + defaultTheme: "Default" + )); // etc. + services.AddSingleton(_ => new LayoutFactory( + appSettings.Layouts.ToRuntimeLayouts() + )); + + // SCOPED (useful for database contexts) + // services.AddDbContext(); + // services.AddScoped(); + // ... other repositories + services.AddScoped(_ => + { + string quotePath = Path.Combine(AppContext.BaseDirectory, "quote.txt"); + string text = File.Exists(quotePath) + ? File.ReadAllTextAsync(quotePath).Result + : "The quick brown fox jumps over the lazy dog."; + + return new StaticTextProvider(text); + }); + // TRANSIENT (a new instance every time) + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} + +// File: src\Typical\TUI\Enums\HorizontalAlignment.cs + +namespace Typical.TUI.Settings; + +public enum HorizontalAlignment +{ + Left, + Center, + Right, +} + +// File: src\Typical\TUI\Enums\LayoutDirection.cs + +namespace Typical.TUI.Settings; + +public enum LayoutDirection +{ + Rows, + Columns, +} + +// File: src\Typical\TUI\Enums\VerticalAlignment.cs + +namespace Typical.TUI.Settings; + +public enum VerticalAlignment +{ + Top, + Middle, + Bottom, +} + +// File: src\Typical\TUI\Runtime\LayoutConversion.cs + +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public static class LayoutConversion +{ + public static LayoutNode ToRuntimeRoot(this LayoutDefinition def, string? name = null) + { + return new LayoutNode( + Ratio: def.Ratio ?? 1, + Direction: ParseDirection(def.SplitDirection, name), + Children: def.Children.ToDictionary( + c => ValidateLayoutSectionName(c.Name), + c => c.ToRuntimeNode() + ) + ); + } + + private static LayoutNode ToRuntimeNode(this LayoutDefinition def) + { + return new LayoutNode( + Ratio: def.Ratio ?? 1, + Direction: ParseDirection(def.SplitDirection, def.Name), + Children: def.Children.ToDictionary( + c => ValidateLayoutSectionName(c.Name), + c => c.ToRuntimeNode() + ) + ); + } + + // Validates that a root layout name is allowed + private static LayoutName ValidateRootLayoutName(string name) + { + var candidate = LayoutName.From(name); + if (!LayoutName.All.Contains(candidate)) + throw new InvalidOperationException( + $"Invalid root layout '{name}'. Allowed roots: {string.Join(", ", LayoutName.All)}" + ); + return candidate; + } + + private static LayoutSection ValidateLayoutSectionName(string name) + { + var candidate = LayoutSection.From(name); + if (!LayoutSection.All.Contains(candidate)) + { + throw new InvalidOperationException( + $"Invalid layout name '{name}'. Allowed: {string.Join(", ", LayoutSection.All)}" + ); + } + + return candidate; + } + + private static LayoutDirection ParseDirection(string? raw, string? context) => + raw switch + { + "Rows" => LayoutDirection.Rows, + "Columns" => LayoutDirection.Columns, + _ => throw new InvalidOperationException( + $"Invalid SplitDirection '{raw}' in layout '{context ?? ""}'" + ), + }; +} + +// File: src\Typical\TUI\Runtime\LayoutFactory.cs + +using Spectre.Console; +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public class LayoutFactory +{ + private readonly RuntimeLayoutDict _presets; + + public LayoutFactory(RuntimeLayoutDict presets) + { + _presets = presets; + } + + public Layout Build(LayoutName rootLayout) + { + if (!_presets.TryGetValue(rootLayout, out var rootDefinition)) + { + return new Layout(rootLayout.Value); + } + + return BuildLayoutFromDefinition(rootDefinition, rootLayout.Value); + } + + private Layout BuildLayoutFromDefinition(LayoutNode node, string name) + { + // Use root or child name + var layout = new Layout(name); + + layout.Ratio(node.Ratio); + + if (node.Children.Count == 0) + return layout; + + var childLayouts = node + .Children.Select(kvp => BuildLayoutFromDefinition(kvp.Value, kvp.Key.Value)) + .ToArray(); + + if (node.Direction == LayoutDirection.Rows) + layout.SplitRows(childLayouts); + else + layout.SplitColumns(childLayouts); + + return layout; + } +} + +// File: src\Typical\TUI\Runtime\LayoutNode.cs + +namespace Typical.TUI.Settings; + +public record LayoutNode( + int Ratio, + LayoutDirection Direction, + Dictionary Children +); + +// File: src\Typical\TUI\Runtime\ThemeConversion.cs + +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public static class ThemeConversion +{ + /// + /// Converts a string-keyed Theme dictionary into a strongly-typed ThemeSettings dictionary. + /// Validates that all keys are defined LayoutName values. + /// + public static RuntimeTheme ToRuntimeTheme(this Theme theme) + { + var result = new RuntimeTheme(); + + foreach (var kvp in theme) + { + var layoutName = ValidateLayoutSection(kvp.Key); + result[layoutName] = kvp.Value; + } + + return result; + } + + /// + /// Converts a ThemeDict (string-keyed themes) to a runtime dictionary keyed by theme name + /// with ThemeSettings as values. + /// + public static Dictionary ToRuntimeThemes(this ThemeDict themeDict) + { + return themeDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToRuntimeTheme()); + } + + private static LayoutSection ValidateLayoutSection(string name) + { + var candidate = LayoutSection.From(name); + if (!LayoutSection.All.Contains(candidate)) + { + throw new InvalidOperationException( + $"Invalid layout section '{name}' in Theme. Allowed values: {string.Join(", ", LayoutName.All)}" + ); + } + + return candidate; + } +} + +public class RuntimeTheme : Dictionary { } + +public class RuntimeThemeDict : Dictionary { } + +public class RuntimeLayoutDict : Dictionary +{ + public RuntimeLayoutDict() + : base() { } + + public RuntimeLayoutDict(Dictionary dictionary) + : base(dictionary) { } +} + +// File: src\Typical\TUI\Settings\AlignmentSettings.cs + +namespace Typical.TUI.Settings; + +public class AlignmentSettings +{ + public VerticalAlignment Vertical { get; set; } + public HorizontalAlignment Horizontal { get; set; } +} + +// File: src\Typical\TUI\Settings\AppSettings.cs + +namespace Typical.TUI.Settings; + +public class AppSettings +{ + public ThemeDict Themes { get; set; } = []; + public LayoutPresetDict Layouts { get; set; } = []; +} + +// File: src\Typical\TUI\Settings\AppSettingsExtensions.cs + +using Typical.TUI.Runtime; + +namespace Typical.TUI.Settings; + +public static class AppSettingsExtensions +{ + public static RuntimeLayoutDict ToRuntimeLayouts(this LayoutPresetDict layoutDict) + { + return new RuntimeLayoutDict( + layoutDict.ToDictionary( + kvp => LayoutName.From(kvp.Key), + kvp => kvp.Value.ToRuntimeRoot() + ) + ); + } + + // Themes can stay string-keyed or convert similarly if needed +} + +// File: src\Typical\TUI\Settings\BorderStyleSettings.cs + +namespace Typical.TUI.Settings; + +public class BorderStyleSettings +{ + public string? ForegroundColor { get; set; } + public string? Decoration { get; set; } +} + +// File: src\Typical\TUI\Settings\ElementStyle.cs + +namespace Typical.TUI.Settings; + +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; +} + +// File: src\Typical\TUI\Settings\LayoutDefinition.cs + +namespace Typical.TUI.Settings; + +public class LayoutDefinition +{ + public string Name { get; set; } = default!; + public int? Ratio { get; set; } = 1; + public string? SplitDirection { get; set; } = "Columns"; + public List Children { get; set; } = []; +} + +// File: src\Typical\TUI\Settings\LayoutName.cs + +using System.Diagnostics.CodeAnalysis; +using Vogen; + +namespace Typical.TUI.Settings; + +[ValueObject(conversions: Conversions.SystemTextJson)] +public partial record LayoutName +{ + public static readonly LayoutName Default = From(nameof(Default)); + public static readonly LayoutName Dashboard = From(nameof(Dashboard)); + + public static readonly IReadOnlySet All = new HashSet + { + Default, + Dashboard, + }; +} + +// File: src\Typical\TUI\Settings\LayoutSection.cs + +using Vogen; + +namespace Typical.TUI.Settings; + +[ValueObject(conversions: Conversions.SystemTextJson)] +public partial record LayoutSection +{ + public static readonly LayoutSection Default = From(nameof(Default)); + public static readonly LayoutSection Header = From(nameof(Header)); + public static readonly LayoutSection Breadcrumb = From(nameof(Breadcrumb)); + public static readonly LayoutSection TypingArea = From(nameof(TypingArea)); + public static readonly LayoutSection Footer = From(nameof(Footer)); + public static readonly LayoutSection GeneralInfo = From(nameof(GeneralInfo)); + public static readonly LayoutSection GameInfo = From(nameof(GameInfo)); + public static readonly LayoutSection TypingInfo = From(nameof(TypingInfo)); + public static readonly LayoutSection Center = From(nameof(Center)); + + public static readonly IReadOnlySet All = new HashSet + { + Default, + Header, + TypingArea, + Footer, + Breadcrumb, + GameInfo, + TypingInfo, + Center, + }; +} + +// File: src\Typical\TUI\Settings\PanelHeaderSettings.cs + +namespace Typical.TUI.Settings; + +public class PanelHeaderSettings +{ + public string? Text { get; set; } +} + +// File: src\Typical\TUI\Settings\ThemeManager.cs + +using Spectre.Console; +using Spectre.Console.Rendering; +using Typical.TUI.Runtime; + +namespace Typical.TUI.Settings; + +public class ThemeManager +{ + private RuntimeTheme _activeTheme; + private readonly Dictionary_themes; + + public string ActiveThemeName { get; private set; } + + public ThemeManager(Dictionary themes, string? defaultTheme = null) + { + if (themes?.Any() != true) + throw new ArgumentException("No themes provided."); + + _themes = themes; + + ActiveThemeName = defaultTheme ?? _themes.Keys.First(); + _activeTheme = _themes[ActiveThemeName]; + } + + public IRenderable Apply(T renderable, LayoutSection layoutName) + where T : IRenderable + { + if (!_activeTheme.TryGetValue(layoutName, out var style)) + { + _activeTheme.TryGetValue(LayoutSection.Default, out style); + } + + style ??= new ElementStyle(); + + if (layoutName == LayoutSection.Header) + style.WrapInPanel = false; + + if (!style.WrapInPanel) + return renderable; + + Panel finalPanel; + + if (renderable is Panel existingPanel) + { + finalPanel = existingPanel; + } + else + { + IRenderable content = renderable; + if (style.Alignment is not null) + { + 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.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); + + finalPanel.BorderStyle = new Style(foreground: foreground, decoration: decoration); + } + + if (style.PanelHeader?.Text is not null) + { + finalPanel.Header = new PanelHeader(style.PanelHeader.Text); + } + return finalPanel.Expand(); + } + + private static Color? ParseColor(string stringColor) + { + if (stringColor.StartsWith('#')) + return Color.FromHex(stringColor); + + return Enum.TryParse(stringColor, out var consoleColor) + ? (Color?)consoleColor + : null; + } + + public bool TrySetTheme(string themeName) + { + bool exists = _themes.TryGetValue(themeName, out var theme); + if (exists && theme is not null) + { + _activeTheme = theme; + } + + return exists && theme is not null; + } +} + +// File: src\Typical\TUI\Settings\ThemeSettings.cs + +namespace Typical.TUI.Settings; + +public class Theme : Dictionary { } + +public class LayoutPresetDict : Dictionary; + +public class ThemeDict : Dictionary { } + +// File: src\Typical\TUI\Views\GameView.cs + +using Spectre.Console; +using Spectre.Console.Rendering; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; + +namespace Typical.TUI.Views; + +public class GameView : IView +{ + private readonly MarkupGenerator _markupGenerator; + + private readonly GameEngine _engine; + private readonly ThemeManager _theme; + private readonly LayoutFactory _layoutFactory; + private readonly IAnsiConsole _console; + private string _targetText = string.Empty; + private string _userInput = string.Empty; + private GameStatisticsSnapshot _statistics = GameStatisticsSnapshot.Empty; + private bool _isGameOver; + private bool _needsRefresh; + + public GameView( + GameEngine engine, + ThemeManager theme, + MarkupGenerator markupGenerator, + LayoutFactory layoutFactory, + IEventAggregator eventAggregator, + IAnsiConsole console + ) + { + _engine = engine; + _theme = theme; + _markupGenerator = markupGenerator; + _layoutFactory = layoutFactory; + _console = console; + + eventAggregator.Subscribe(OnGameStateUpdated); + } + + private void OnGameStateUpdated(GameStateUpdatedEvent e) + { + // Cache the new state + _targetText = e.TargetText; + _userInput = e.UserInput; + _statistics = e.Statistics; + _isGameOver = e.IsOver; + + // Set a single flag to refresh the entire UI + _needsRefresh = true; + } + + public async Task RenderAsync() + { + var layout = _layoutFactory.Build(LayoutName.Dashboard); + await _console + .Live(layout) + .StartAsync(async ctx => + { + await _engine.StartNewGame(); + 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; + int lastWidth = Console.WindowWidth; + + while (!_isGameOver) + { + if (Console.WindowWidth != lastWidth || Console.WindowHeight != lastHeight) + { + lastWidth = Console.WindowWidth; + lastHeight = Console.WindowHeight; + _needsRefresh = true; + } + + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + if (!_engine.ProcessKeyPress(key)) + break; + } + + if (_needsRefresh) + { + layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea()); + layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea()); + ctx.Refresh(); + _needsRefresh = false; // Reset the flag + } + + if (_isGameOver) + { + ctx.Refresh(); + Thread.Sleep(500); + break; + } + + Thread.Sleep(_engine.TargetFrameDelayMilliseconds); + } + }); + + DisplaySummary(); + } + + private IRenderable CreateGameInfoArea() + { + // Use the cached statistics object + if (_statistics is null) + return new Text(""); + + var grid = new Grid(); + grid.AddColumns([new GridColumn(), new GridColumn()]); + grid.AddRow("WPM:", $"{_statistics.WordsPerMinute:F1}"); + grid.AddRow("Accuracy:", $"{_statistics.Accuracy:F1}%"); + grid.AddRow("Correct Chars:", $"{_statistics.Chars.Correct}"); + grid.AddRow("Incorrect Chars:", $"{_statistics.Chars.Incorrect}"); + grid.AddRow("Extra Chars:", $"{_statistics.Chars.Extra}"); + grid.AddRow("Elapsed:", $"{_statistics.ElapsedTime:mm\\:ss}"); + return _theme.Apply(grid, LayoutSection.GameInfo); + } + + private IRenderable CreateTypingArea() + { + // Use the cached text fields + var markup = _markupGenerator.BuildMarkupOptimized(_targetText, _userInput); + return _theme.Apply(markup, LayoutSection.TypingArea); + } + + private IRenderable CreateHeader() + { + return _theme.Apply(new Markup("Typical - A Typing Tutor"), LayoutSection.Header); + } + + private Action DisplaySummary() => + summaryString => AnsiConsole.MarkupLineInterpolated($"[bold green]{summaryString}[/]"); +} + +// File: src\Typical\TUI\Views\IView.cs + +namespace Typical.TUI.Views; + +public interface IView +{ + // Renders the content of the view. + Task RenderAsync(); +} + +public class StatsView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} + +public class MainMenuView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} + +// File: src\Typical\TUI\AppShell.cs + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI.Views; + +namespace Typical.TUI; + +public class AppShell +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger_logger; + + public AppShell(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task RunAsync() + { + AppLogs.ApplicationStarting(_logger); + IView currentView = _serviceProvider.GetRequiredService(); + + if (currentView != null) + { + await currentView.RenderAsync(); + + // The view's RenderAsync method would return the next view to transition to, + // or null to quit. + // e.g., MainMenuView returns a new GameView when the user selects "Start". + // currentView = await currentView.GetNextViewAsync(); + } + AppLogs.ApplicationStopping(_logger); + } +} + +// File: src\Typical\ApplicationCommands.cs + +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI; +using Typical.TUI.Views; + +namespace Typical; + +// The [Command] attribute on the class is optional but good practice. +public class ApplicationCommands +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger_logger; + + // The DI container will inject the services we need here. + public ApplicationCommands( + IServiceProvider serviceProvider, + ILogger logger + ) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// The entry point for interactive mode (when no arguments are given). + /// + [Command("")] + public async Task RunInteractive() + { + AppLogs.NoCommandsInteractive(_logger); + + // Resolve the AppShell from the DI container and run it. + var appShell = _serviceProvider.GetRequiredService(); + await appShell.RunAsync(); + } + + /// + /// Directly starts a typing game, bypassing the main menu. + /// + [Command("play")] + public async Task Play(string mode = "Quote", int duration = 60) + { + AppLogs.StartingGame(_logger, mode, duration); + + // Resolve a GameView directly from the DI container. + // This is a "one-shot" game session. + var gameView = _serviceProvider.GetRequiredService(); + + // We would need to pass these options to the GameView to configure the game. + // For example: await gameView.RunAsync(new GameOptions { Mode = mode, Duration = duration }); + await gameView.RenderAsync(); // Simplified for this example + } + + /// + /// Displays user statistics directly. + /// + [Command("stats")] + public async Task ShowStats() + { + _logger.LogInformation("Displaying stats view."); + var statsView = _serviceProvider.GetRequiredService(); + await statsView.RenderAsync(); + } +} + +// File: src\Typical\ConfigurationExtensions.cs + +// using Microsoft.Extensions.Configuration; +// using Typical.TUI; +// using Typical.TUI.Settings; + +// namespace Typical; + +// public static class ConfigurationExtensions +// { +// public static RuntimeTheme GetThemeSettings(this IConfiguration configuration) +// { +// var section = configuration.GetSection("Theme"); +// var dict = new RuntimeTheme(); + +// foreach (var child in section.GetChildren()) +// { +// var key = LayoutName.From(child.Key); +// var value = child.Get(); +// dict[key] = value; +// } + +// return dict; +// } + +// // TODO:Not working +// public static LayoutPresetDict GetLayoutPresets(this IConfiguration configuration) +// { +// var section = configuration.GetSection("Layouts"); +// var dict = new LayoutPresetDict(); + +// foreach (var child in section.GetChildren()) +// { +// var key = child.Key; +// var value = child.Get(); +// dict[key] = value; +// } + +// return dict; +// } +// } + +// File: src\Typical\MarkupGenerator.cs + +using System.Text; +using Spectre.Console; + +namespace Typical; + +public class MarkupGenerator +{ + public Markup BuildMarkupOptimized(string target, string typed) + { + return new Markup(BuildMarkupString(target, typed)); + } + + internal string BuildMarkupString(string target, string typed) + { + if (string.IsNullOrEmpty(target)) + { + return string.Empty; + } + + var builder = new StringBuilder(); + var typedLength = typed.Length; + TypingResult currentState = TypingResult.Untyped; + + if (typedLength > 0 && target.Length > 0) + { + currentState = target[0] == typed[0] ? TypingResult.Correct : TypingResult.Incorrect; + } + builder.Append(GetMarkupForState(currentState)); + + for (int i = 0; i < target.Length; i++) + { + TypingResult charState; + if (i >= typedLength) + { + charState = TypingResult.Untyped; + } + else + { + charState = target[i] == typed[i] ? TypingResult.Correct : TypingResult.Incorrect; + } + + if (charState != currentState) + { + builder.Append("[/]"); + builder.Append(GetMarkupForState(charState)); + currentState = charState; + } + var escapedChar = Markup.Escape(target[i].ToString()); + + if (i == typedLength) + { + builder.Append($"[underline]{escapedChar}[/]"); + } + else + { + builder.Append(escapedChar); + } + } + + builder.Append("[/]"); + + if (typedLength > target.Length) + { + builder.Append(GetMarkupForState(TypingResult.Incorrect)); + builder.Append($"{Markup.Escape(typed.Substring(target.Length))}"); + builder.Append("[/]"); + } + + return builder.ToString(); + } + + private string GetMarkupForState(TypingResult state) => + state switch + { + TypingResult.Correct => "[default on green]", + TypingResult.Incorrect => "[red on grey15]", + _ => "[grey]", + }; +} + +// File: src\Typical\Program.cs + +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Typical; +using Typical.Services; +using Velopack; + +VelopackApp.Build().Run(); + +var services = new ServiceCollection(); + +services.RegisterAppServices(); + +ConsoleApp.ServiceProvider = services.BuildServiceProvider(); + +var app = ConsoleApp.Create(); + +app.Add(); + +await app.RunAsync(args); + +// File: src\Typical\StaticTextProvider.cs + +using Typical.Core.Text; + +namespace Typical; + +internal class StaticTextProvider(string text) : ITextProvider +{ + private readonly string _text = text; + + public Task GetTextAsync() => Task.FromResult(_text); +} + +// File: src\Typical\TypingResult.cs + +namespace Typical; + +internal enum TypingResult +{ + Untyped, + Correct, + Incorrect, +} + +// File: src\Typical.Core\Events\BackspacePressedEvent.cs + +namespace Typical.Core.Events; + +internal record BackspacePressedEvent; + +// File: src\Typical.Core\Events\GameEndedEvent.cs + +namespace Typical.Core.Events; + +public record GameEndedEvent; + +// File: src\Typical.Core\Events\GameQuitEvent.cs + +namespace Typical.Core.Events; + +public record GameQuitEvent; + +// File: src\Typical.Core\Events\GameStateUpdatedEvent.cs + +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +public record GameStateUpdatedEvent( + string TargetText, + string UserInput, + GameStatisticsSnapshot Statistics, + bool IsOver +); + +// File: src\Typical.Core\Events\IEventAggregator.cs + +using System.Reflection.Metadata; + +namespace Typical.Core.Events; + +public interface IEventAggregator +{ + void Subscribe(Action handler) + where TEvent : class; + void Unsubscribe(Action handler) + where TEvent : class; + void Publish(TEvent eventToPublish) + where TEvent : class; +} + +public class EventAggregator : IEventAggregator +{ + private readonly Dictionary> _handlers = []; + private readonly Lock_aggregatorLock = new(); + + public void Subscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out List? value)) + { + value = []; + _handlers[eventType] = value; + } + + value.Add(handler); + } + } + + public void Unsubscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + lock (_aggregatorLock) + { + if (_handlers.TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers.Remove(handler); + + if (eventHandlers.Count == 0) + { + _handlers.Remove(eventType); + } + } + } + } + + public void Publish(TEvent eventToPublish) + where TEvent : class + { + var eventType = typeof(TEvent); + List handlersSnapshot; + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out var eventHandlers)) + { + return; + } + + handlersSnapshot = eventHandlers.ToList(); + } + + foreach (var handler in handlersSnapshot) + { + ((Action)handler)(eventToPublish); + } + } +} + +// File: src\Typical.Core\Events\KeyPressedEvent.cs + +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +internal record KeyPressedEvent(char Character, KeystrokeType Type, int Position); + +// File: src\Typical.Core\Statistics\CharacterStats.cs + +namespace Typical.Core.Statistics; + +// A simple record to hold the results of GetCharacterStats +public record CharacterStats(int Correct, int Incorrect, int Extra, int Corrections); + +// File: src\Typical.Core\Statistics\GameStatisticsSnapshot.cs + +namespace Typical.Core.Statistics; + +public record GameStatisticsSnapshot( + double WordsPerMinute, + double Accuracy, + CharacterStats Chars, + TimeSpan ElapsedTime, + bool IsRunning +) +{ + public static GameStatisticsSnapshot Empty => + new(0, 100, new CharacterStats(0, 0, 0, 0), TimeSpan.Zero, false); +} + +// File: src\Typical.Core\Statistics\GameStats.cs + +using Typical.Core.Events; + +namespace Typical.Core.Statistics; + +internal class GameStats +{ + private readonly IEventAggregator _eventAggregator; + private readonly TimeProvider_timeProvider; + private readonly KeystrokeHistory _keystrokeHistory = []; + private long?_startTimestamp; + private long? _endTimestamp; + private bool_statsAreDirty = true; + private double _cachedWpm; + private double_cachedAccuracy; + private CharacterStats _cachedChars = new(0, 0, 0, 0); + + public GameStats(IEventAggregator eventAggregator, TimeProvider? timeProvider = null) + { + _eventAggregator = eventAggregator; + _timeProvider = timeProvider ?? TimeProvider.System; + _eventAggregator.Subscribe(OnKeyPressed); + _eventAggregator.Subscribe(OnBackspacePressed); + } + + private void OnBackspacePressed(BackspacePressedEvent @event) + { + if (!IsRunning) + { + return; + } + + _keystrokeHistory.RemoveLastCharacterLog(); + _keystrokeHistory.Add( + new KeystrokeLog('\b', KeystrokeType.Correction, _timeProvider.GetTimestamp()) + ); + + _statsAreDirty = true; + } + + private void OnKeyPressed(KeyPressedEvent @event) + { + if (!IsRunning) + { + Start(); + } + + _keystrokeHistory.Add( + new KeystrokeLog(@event.Character, @event.Type, _timeProvider.GetTimestamp()) + ); + _statsAreDirty = true; + } + + 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, 0); + } + + public void Stop() + { + if (IsRunning) + { + _endTimestamp = _timeProvider.GetTimestamp(); + } + } + + public GameStatisticsSnapshot CreateSnapshot() + { + if (_statsAreDirty) + { + RecalculateAllStats(); + } + + return new GameStatisticsSnapshot( + WordsPerMinute: _cachedWpm, + Accuracy: _cachedAccuracy, + Chars: _cachedChars, + ElapsedTime: this.ElapsedTime, + IsRunning: this.IsRunning + ); + } + + private void RecalculateAllStats() + { + _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime); + _cachedAccuracy = _keystrokeHistory.CalculateAccuracy(); + _cachedChars = _keystrokeHistory.GetCharacterStats(); + + _statsAreDirty = false; + } +} + +// File: src\Typical.Core\Statistics\KeystrokeHistory.cs + +using System.Collections; + +namespace Typical.Core.Statistics; + +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, int Corrections) GetCounts() + { + int correct = 0; + int incorrect = 0; + int extra = 0; + int corrections = 0; + + foreach (var log in _logs) + { + switch (log.Type) + { + case KeystrokeType.Correct: + correct++; + break; + case KeystrokeType.Incorrect: + incorrect++; + break; + case KeystrokeType.Extra: + extra++; + break; + case KeystrokeType.Correction: + corrections++; + break; + } + } + return (correct, incorrect, extra, corrections); + } + + 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, + Corrections: counts.Corrections + ); + } + + public IEnumerator GetEnumerator() => _logs.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void RemoveLastCharacterLog() + { + // Use FindLastIndex to search from the end of the list. + int indexToRemove = _logs.FindLastIndex(log => + log.Type == KeystrokeType.Correct + || log.Type == KeystrokeType.Incorrect + || log.Type == KeystrokeType.Extra + ); + + // If a log was found (index is not -1), remove it. + if (indexToRemove != -1) + { + _logs.RemoveAt(indexToRemove); + } + } +} + +// File: src\Typical.Core\Statistics\KeystrokeLog.cs + +namespace Typical.Core.Statistics; + +public record struct KeystrokeLog(char Character, KeystrokeType Type, long Timestamp); + +// File: src\Typical.Core\Statistics\KeystrokeType.cs + +namespace Typical.Core.Statistics; + +public enum KeystrokeType +{ + Correct, + Incorrect, + Extra, + Correction, +} + +// File: src\Typical.Core\Text\ITextProvider.cs + +namespace Typical.Core.Text; + +public interface ITextProvider +{ + Task GetTextAsync(); +} + +// File: src\Typical.Core\GameEngine.cs + +using System.Text; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class GameEngine +{ + private readonly StringBuilder _userInput; + private readonly ITextProvider_textProvider; + private readonly GameOptions _gameOptions; + private readonly IEventAggregator_eventAggregator; + private readonly GameStats _stats; + + public GameEngine(ITextProvider textProvider, IEventAggregator eventAggregator) + : this(textProvider, eventAggregator, new GameOptions()) { } + + public GameEngine( + ITextProvider textProvider, + IEventAggregator eventAggregator, + GameOptions gameOptions + ) + { + _textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider)); + _gameOptions = gameOptions; + _userInput = new StringBuilder(); + _eventAggregator = eventAggregator; + + _stats = new GameStats(_eventAggregator); + } + + 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 bool ProcessKeyPress(ConsoleKeyInfo key) + { + if (key.Key == ConsoleKey.Escape) + { + IsOver = true; + _stats.Stop(); + _eventAggregator.Publish(new GameQuitEvent()); + return false; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (_userInput.Length > 0) + { + _userInput.Remove(_userInput.Length - 1, 1); + _eventAggregator.Publish(new BackspacePressedEvent()); + PublishStateUpdate(); + } + return true; + } + + if (char.IsControl(key.KeyChar)) + { + return true; + } + char inputChar = key.KeyChar; + KeystrokeType type = DetermineKeystrokeType(inputChar); + + _eventAggregator.Publish(new KeyPressedEvent(inputChar, type, _userInput.Length)); + + bool isCorrect = type == KeystrokeType.Correct; + if (!_gameOptions.ForbidIncorrectEntries || isCorrect) + { + _userInput.Append(key.KeyChar); + } + + CheckEndCondition(); + PublishStateUpdate(); + + return true; + } + + private KeystrokeType DetermineKeystrokeType(char inputChar) + { + int currentPos = _userInput.Length; + + if (currentPos >= TargetText.Length) + { + return KeystrokeType.Extra; + } + + if (inputChar == TargetText[currentPos]) + { + return KeystrokeType.Correct; + } + + return KeystrokeType.Incorrect; + } + + private void CheckEndCondition() + { + if (_userInput.ToString() == TargetText) + { + IsOver = true; + _stats.Stop(); + + _eventAggregator.Publish(new GameEndedEvent()); + } + } + + public async Task StartNewGame() + { + TargetText = await _textProvider.GetTextAsync(); + _stats.Start(); + _userInput.Clear(); + IsOver = false; + PublishStateUpdate(); + } + + private void PublishStateUpdate() + { + var snapShot = _stats.CreateSnapshot(); + var stateEvent = new GameStateUpdatedEvent(TargetText, UserInput, snapShot, IsOver); + _eventAggregator.Publish(stateEvent); + } +} + +// File: src\Typical.Core\GameOptions.cs + +namespace Typical.Core; + +public record GameOptions +{ + public static GameOptions Default { get; set; } = new(); + public bool ForbidIncorrectEntries { get; set; } = false; + public int TargetFrameRate { get; set; } = 60; + // Future options could be added here: + // public int TimeLimitSeconds { get; set; } = 0; // 0 for no limit + // public bool ShowLiveWpm { get; set; } = false; +} + +// File: src\Typical.Tests\Core\GameStatsTests.cs + +using System; +using Microsoft.Extensions.Time.Testing; +using TUnit; +using Typical.Core.Statistics; + +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); + } + } +} + +// File: src\Typical.Tests\TUI\LayoutFactoryTests.cs + +// using Spectre.Console; +// using Spectre.Console.Rendering; +// using Typical.TUI.Runtime; +// using Typical.TUI.Settings; + +// namespace Typical.Tests.TUI; + +// public class LayoutFactoryTests +// { +// private readonly IRenderable _testRenderable = new Text("Test Content"); + +// [Test] +// public async Task Constructor_WhenGivenNullConfiguration_DoesNotThrow() +// { +// // Arrange & Act +// var factoryAction = () => new LayoutFactory(null!); + +// // Assert +// await Assert.That(factoryAction.Invoke).ThrowsNothing(); +// } + +// [Test] +// public async Task GetContentFor_WhenContentExistsInConfiguration_ReturnsLayoutWithCorrectNameAndRenderable() +// { +// // Arrange +// var config = new RuntimeLayoutDict(); +// var factory = new LayoutFactory(config); + +// // Act +// var resultLayout = factory.GetContentFor(LayoutSection.Header); + +// // Assert +// await Assert.That(resultLayout).IsNotNull(); +// await Assert.That(LayoutSection.Header.Value).IsEqualTo(resultLayout.Name); +// // await Assert.That(_testRenderable, resultLayout.Renderable).AreSame(); +// } + +// [Test] +// public async Task GetContentFor_WhenContentDoesNotExistInConfiguration_ReturnsLayoutWithCorrectNameAndNullRenderable() +// { +// // Arrange +// var config = LayoutConfiguration.Default; +// var factory = new LayoutFactory(config); + +// // Act +// var resultLayout = factory.GetContentFor(LayoutSection.Footer); + +// // Assert +// await Assert.That(resultLayout).IsNotNull(); +// await Assert.That(LayoutSection.Footer.Value).IsEqualTo(resultLayout.Name); +// // await Assert.That(resultLayout.Renderable).IsNull(); // TODO: Use IAnsiConsole TestConsole +// } + +// [Test] +// public async Task BuildClassicFocus_WithEmptyConfiguration_BuildsSuccessfully() +// { +// // Arrange +// var factory = new LayoutFactory(LayoutConfiguration.Default); + +// // Act +// var layout = factory.Build(LayoutName.Dashboard); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(LayoutName.Dashboard.Value).IsEqualTo(layout.Name); +// } + +// [Test] +// public async Task Build_WhenRootLayoutNotInPresets_ReturnsEmptyRootLayout() +// { +// // Arrange +// var factory = new LayoutFactory(new RuntimeLayoutDict()); + +// // Act +// var layout = factory.Build(LayoutName.Root); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(layout.Name).IsEqualTo(LayoutName.Root.Value); +// await Assert.That(layout.Children).IsEmpty(); +// } + +// [Test] +// public async Task Build_ReturnsRootLayout() +// { +// // Arrange +// var factory = new LayoutFactory(); + +// // Act +// var layout = factory.Build(); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(LayoutSection.Root.Value).IsEqualTo(layout.Name); +// // await Assert.That(layout.Renderable).IsNull(); // TODO: Use IAnsiConsole TestConsole +// } +// } + +// File: src\Typical.Tests\TUI\ThemeSettingsBindingTests.cs + +// using System.Collections.Generic; +// using System.ComponentModel; +// using System.Threading.Tasks; +// using Microsoft.Extensions.Configuration; +// using TUnit; +// using Typical.TUI.Runtime; +// using Typical.TUI.Settings; + +// namespace Typical.TUI.Tests; + +// public class ThemeSettingsBindingTests +// { +// [Test] +// public async Task Can_Bind_ThemeSettings_With_LayoutName_Keys() +// { +// // Arrange: fake config JSON in memory +// var json = +// @" +// { +// ""Theme"": { +// ""Header"": { +// ""PanelHeader"": { ""Text"": ""HeaderText"" } +// }, +// ""TypingArea"": { +// ""PanelHeader"": { ""Text"": ""TypingText"" } +// } +// } +// }"; + +// var configuration = new ConfigurationBuilder() +// .AddJsonStream(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))) +// .Build(); + +// // Act: bind into our strongly-typed ThemeSettings +// var themeSettings = configuration.GetSection("Theme").Get(); + +// // Assert: dictionary has LayoutName keys, not strings +// await Assert.That(themeSettings).IsNotNull(); +// await Assert.That(themeSettings!.ContainsKey(LayoutSection.Header)).IsTrue(); +// await Assert.That(themeSettings!.ContainsKey(LayoutSection.TypingArea)).IsTrue(); + +// // And check values came through +// await Assert +// .That(themeSettings[LayoutSection.Header].PanelHeader?.Text) +// .IsEqualTo("HeaderText"); +// await Assert +// .That(themeSettings[LayoutSection.TypingArea].PanelHeader?.Text) +// .IsEqualTo("TypingText"); +// } +// } + +// File: src\Typical.Tests\TUI\ThemeTests.cs + +using Spectre.Console; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; + +public class ThemeTests +{ + // --- Basic Styling Tests --- + + [Test] + public async Task Apply_WithSpecificStyle_SetsPanelBorderStyle() + { + // Arrange + var theme = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "Blue" }, + } + }, + }; + var themeDict = new Dictionary { { "default", theme } }; + var manager = new ThemeManager(themeDict); + var layoutName = LayoutSection.From("TestArea"); + + // Act + var panel = new Panel(""); + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle).IsNotNull(); + await Assert.That(panel.BorderStyle!.Foreground).IsEqualTo(Color.Blue); + } + + [Test] + public async Task Apply_WithSpecificStyle_SetsPanelHeader() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle { PanelHeader = new PanelHeaderSettings { Text = "Hello" } } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + var manager = new ThemeManager(themeDict); + + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.Header).IsNotNull(); + await Assert.That(panel.Header!.Text).IsEqualTo("Hello"); + } + + [Test] + public async Task Apply_WithHexColor_CorrectlyParsesAndSetsColor() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "#FF00FF" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Foreground).IsEqualTo(new Color(255, 0, 255)); + } + + [Test] + public async Task Apply_WithDecoration_CorrectlyParsesAndSetsDecoration() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { Decoration = "Underline" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Decoration).IsEqualTo(Decoration.Underline); + } + + // --- Fallback and Edge Case Tests --- + + [Test] + public async Task Apply_WhenStyleIsMissing_FallsBackToDefaultStyle() + { + // Arrange + var settings = new RuntimeTheme + { + // Note: "TestArea" is missing, but "Default" is present. + { + LayoutSection.From("Default"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "Red" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); // Requesting a style that doesn't exist + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Foreground).IsEqualTo(Color.Red); + } + + [Test] + public async Task Apply_WhenNoSpecificOrDefaultStyle_DoesNotChangePanel() + { + // Arrange + var settings = new RuntimeTheme(); // Completely empty settings + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var originalPanel = new Panel(""); + // Manually set a border to ensure it doesn't get overwritten + originalPanel.BorderStyle = new Style(Color.Green); + + // Act + manager.Apply(originalPanel, LayoutSection.From("NonExistent")); + + // Assert + // The panel's style should be unchanged from its original state. + await Assert.That(originalPanel.BorderStyle?.Foreground).IsEqualTo(Color.Green); + await Assert.That(originalPanel.Header).IsNull(); + } + + [Test] + public async Task Apply_WithOnlyPartialStyleInfo_AppliesOnlyWhatIsProvided() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle { BorderStyle = new BorderStyleSettings { Decoration = "Bold" } } + }, + // Note: ForegroundColor and PanelHeader are missing from the config. + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle).IsNotNull(); + // Foreground should be the default, not null. + await Assert.That(panel.BorderStyle!.Foreground).IsEqualTo(Color.Default); + await Assert.That(panel.BorderStyle.Decoration).IsEqualTo(Decoration.Bold); + await Assert.That(panel.Header).IsNull(); // Header should not have been set. + } + + // NOTE: The `Alignment` properties are not directly testable on the `Panel` itself, + // because the `Apply` method returns a *new wrapper object* (`Align`) when alignment is set. + // Testing this would require checking the type of the returned object, which is + // more complex and often considered an implementation detail. For now, testing the + // direct mutations of the panel provides excellent coverage of the core logic. +} + +// File: src\Typical.Tests\GameEngineTests.cs + +using Typical.Core; + +namespace Typical.Tests; + +public class TypicalGameTests +{ + private readonly MockTextProvider _mockTextProvider; + private readonly GameOptions_defaultOptions; + private readonly GameOptions _strictOptions; + + public TypicalGameTests() + { + // This runs before each test, ensuring a clean state. + _mockTextProvider = new MockTextProvider(); + _defaultOptions = new GameOptions(); + _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; + } + + // --- StartNewGame Tests --- + + [Test] + public async Task StartNewGame_Always_LoadsTextFromProvider() + { + // Arrange + var expectedText = "This is a test."; + _mockTextProvider.SetText(expectedText); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + + // Act + await game.StartNewGame(); + + // Assert + await Assert.That(game.TargetText).IsEqualTo(expectedText); + } + + [Test] + public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() + { + // Arrange + _mockTextProvider.SetText("some text"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + + // Simulate playing the game + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + game.ProcessKeyPress( + new ConsoleKeyInfo((char)ConsoleKey.Escape, ConsoleKey.Escape, false, false, false) + ); + await Assert.That(game.IsOver).IsTrue(); + await Assert.That(game.UserInput).IsNotEmpty(); + + // Act + _mockTextProvider.SetText("new text"); + await game.StartNewGame(); + + // Assert + await Assert.That(game.IsOver).IsFalse(); + await Assert.That(game.UserInput).IsEmpty(); + await Assert.That(game.TargetText).IsEqualTo("new text"); + } + + // --- ProcessKeyPress Tests --- + + [Test] + public async Task ProcessKeyPress_EscapeKey_EndsGameAndReturnsFalse() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + + // Act + var result = game.ProcessKeyPress( + new ConsoleKeyInfo((char)ConsoleKey.Escape, ConsoleKey.Escape, false, false, false) + ); + + // Assert + await Assert.That(result).IsFalse(); + await Assert.That(game.IsOver).IsTrue(); + } + + [Test] + public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + game.ProcessKeyPress(new ConsoleKeyInfo('b', ConsoleKey.B, false, false, false)); + await Assert.That(game.UserInput).IsEqualTo("ab"); + + // Act + game.ProcessKeyPress( + new ConsoleKeyInfo( + (char)ConsoleKey.Backspace, + ConsoleKey.Backspace, + false, + false, + false + ) + ); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress( + new ConsoleKeyInfo( + (char)ConsoleKey.Backspace, + ConsoleKey.Backspace, + false, + false, + false + ) + ); + + // Assert + await Assert.That(game.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() + { + // Arrange + _mockTextProvider.SetText("hi"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('h', ConsoleKey.H, false, false, false)); + game.ProcessKeyPress(new ConsoleKeyInfo('i', ConsoleKey.I, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("hi"); + await Assert.That(game.IsOver).IsTrue(); + } + + // --- GameOptions: ForbidIncorrectEntries Tests --- + + [Test] + public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _strictOptions); + await game.StartNewGame(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _strictOptions); + await game.StartNewGame(); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("x"); + } +} + +// File: src\Typical.Tests\MarkupGeneratorTests.cs + +using Typical; // Your project's namespace + +public class MarkupGeneratorTests +{ + private readonly MarkupGenerator _generator; + + public MarkupGeneratorTests() + { + // Create a new instance for each test to ensure isolation. + _generator = new MarkupGenerator(); + } + + // --- Core Scenarios --- + + [Test] + public async Task BuildMarkupOptimized_AllCorrectlyTyped_ReturnsFullyCorrectMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hello world"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[default on green]Hello world[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_PartiallyTypedAndCorrect_ReturnsCorrectAndUntypedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hello"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert + .That(result) + .IsEqualTo("[default on green]Hello[/][grey][underline] [/]world[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_WithErrors_ReturnsMixedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hellx worlb"; // Two errors + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // "Hell" is correct, "o" is incorrect, " worl" is correct, "d" is incorrect. + var expected = + "[default on green]Hell[/][red on grey15]o[/][default on green] worl[/][red on grey15]d[/]"; + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task BuildMarkupOptimized_NothingTyped_ReturnsFullyUntypedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = ""; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[grey][underline]H[/]ello world[/]"); + } + + // --- Edge Cases --- + + [Test] + public async Task BuildMarkupOptimized_EmptyTarget_ReturnsEmptyMarkup() + { + // Arrange + var target = ""; + var typed = "some input"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo(string.Empty); + } + + [Test] + public async Task BuildMarkupOptimized_UserTypedExtraCharacters_ShowsExtraCharsAsIncorrect() + { + // Arrange + var target = "Hello"; + var typed = "Hello world"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // "Hello" is correct, " world" is the extra incorrect part. + var expected = "[default on green]Hello[/][red on grey15] world[/]"; + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task BuildMarkupOptimized_AllCharactersIncorrect_ReturnsFullyIncorrectMarkup() + { + // Arrange + var target = "abcde"; + var typed = "fghij"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[red on grey15]abcde[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_TargetContainsMarkupCharacters_EscapesThemCorrectly() + { + // Arrange + var target = "[[Hello]]"; + var typed = "[[Hello]]"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // The generator should escape the brackets so Spectre.Console doesn't interpret them. + await Assert.That(result).IsEqualTo("[default on green][[[[Hello]]]][/]"); + } +} + +// File: src\Typical.Tests\MockTextProvider.cs + +using Typical.Core.Text; + +namespace Typical.Tests; + +public class MockTextProvider : ITextProvider +{ + private string _textToReturn = string.Empty; + + public void SetText(string text) + { + _textToReturn = text; + } + + public Task GetTextAsync() + { + // Task.FromResult is the perfect way to simulate an + // async operation that completes immediately. + return Task.FromResult(_textToReturn); + } +} diff --git a/.github/instructions/codebase.txt b/.github/instructions/codebase.txt new file mode 100644 index 0000000..7bbac83 --- /dev/null +++ b/.github/instructions/codebase.txt @@ -0,0 +1,2507 @@ + - Typical + - Typical.Core + - Typical.Tests + - Commands + - Configuration + - Data + - Filters + - Logging + - Properties + - Services + - TUI + - Enums + - Runtime + - Settings + - Views + - Events + - Statistics + - Text + - Core + - TUI + +# --- Start of Code Files --- + + + +// File: src\Typical\Logging\AppLogs.cs + +using Microsoft.Extensions.Logging; +using Typical; +using Typical.TUI; + +public static partial class AppLogs +{ + // Define a log message with ID, level, template + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Application starting...")] + public static partial void ApplicationStarting(ILogger logger); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "No commands specified, starting interactive AppShell." + )] + public static partial void NoCommandsInteractive(ILogger logger); + + // Example with parameters + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Failed to process user {UserId}" + )] + public static partial void FailedToProcessUser(ILogger logger, int userId); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "Starting direct game with Mode: {Mode}, Duration: {Duration}" + )] + public static partial void StartingGame(ILogger logger, string mode, int duration); + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = ("Application shutting down.") + )] + public static partial void ApplicationStopping(ILogger logger); +} + + +// File: src\Typical\Logging\SourceClassEnricher.cs + +using Serilog.Core; +using Serilog.Events; + +public class SourceClassEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if ( + logEvent.Properties.TryGetValue("SourceContext", out var value) + && value is ScalarValue sv + && sv.Value is string fullName + ) + { + var shortName = fullName.Split('.').Last(); + var property = propertyFactory.CreateProperty("SourceClass", shortName); + logEvent.AddOrUpdateProperty(property); + } + } +} + + +// File: src\Typical\Services\ServiceExtensions.cs + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Formatting.Display; +using Serilog.Sinks.SystemConsole.Themes; +using Spectre.Console; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Text; +using Typical.TUI; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; +using Typical.TUI.Views; + +namespace Typical.Services; + +public static class ServiceExtensions +{ + public static IConfiguration CreateConfiguration() => + new ConfigurationBuilder().AddJsonFile("./config.json", false).Build(); + + public static void ConfigureSerilog(this ILoggingBuilder builder) + { + const string outputTemplate = + "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceClass}) {Message:lj}{NewLine}{Exception}"; + builder.AddSerilog( + new LoggerConfiguration() + .WriteTo.File( + formatter: new MessageTemplateTextFormatter(outputTemplate), + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "app-.log"), + shared: true, + rollingInterval: RollingInterval.Day + ) + .Enrich.WithProperty("ApplicationName", "") + .Enrich.With() + .WriteTo.Console(outputTemplate: outputTemplate, theme: AnsiConsoleTheme.Sixteen) + .CreateLogger() + ); + } + + public static IServiceCollection RegisterAppServices(this IServiceCollection services) + { + var configuration = CreateConfiguration(); + var appSettings = configuration.Get()!; + services.AddLogging(ConfigureSerilog); + + services.AddSingleton(configuration); + + services.AddSingleton(); + services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(_ => new ThemeManager( + appSettings.Themes.ToRuntimeThemes(), + defaultTheme: "Default" + )); // etc. + services.AddSingleton(_ => new LayoutFactory( + appSettings.Layouts.ToRuntimeLayouts() + )); + + // SCOPED (useful for database contexts) + // services.AddDbContext(); + // services.AddScoped(); + // ... other repositories + services.AddScoped(_ => + { + string quotePath = Path.Combine(AppContext.BaseDirectory, "quote.txt"); + string text = File.Exists(quotePath) + ? File.ReadAllTextAsync(quotePath).Result + : "The quick brown fox jumps over the lazy dog."; + + return new StaticTextProvider(text); + }); + // TRANSIENT (a new instance every time) + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} + + +// File: src\Typical\TUI\Enums\HorizontalAlignment.cs + +namespace Typical.TUI.Settings; + +public enum HorizontalAlignment +{ + Left, + Center, + Right, +} + + +// File: src\Typical\TUI\Enums\LayoutDirection.cs + +namespace Typical.TUI.Settings; + +public enum LayoutDirection +{ + Rows, + Columns, +} + + +// File: src\Typical\TUI\Enums\VerticalAlignment.cs + +namespace Typical.TUI.Settings; + +public enum VerticalAlignment +{ + Top, + Middle, + Bottom, +} + + +// File: src\Typical\TUI\Runtime\LayoutConversion.cs + +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public static class LayoutConversion +{ + public static LayoutNode ToRuntimeRoot(this LayoutDefinition def, string? name = null) + { + return new LayoutNode( + Ratio: def.Ratio ?? 1, + Direction: ParseDirection(def.SplitDirection, name), + Children: def.Children.ToDictionary( + c => ValidateLayoutSectionName(c.Name), + c => c.ToRuntimeNode() + ) + ); + } + + private static LayoutNode ToRuntimeNode(this LayoutDefinition def) + { + return new LayoutNode( + Ratio: def.Ratio ?? 1, + Direction: ParseDirection(def.SplitDirection, def.Name), + Children: def.Children.ToDictionary( + c => ValidateLayoutSectionName(c.Name), + c => c.ToRuntimeNode() + ) + ); + } + + // Validates that a root layout name is allowed + private static LayoutName ValidateRootLayoutName(string name) + { + var candidate = LayoutName.From(name); + if (!LayoutName.All.Contains(candidate)) + throw new InvalidOperationException( + $"Invalid root layout '{name}'. Allowed roots: {string.Join(", ", LayoutName.All)}" + ); + return candidate; + } + + private static LayoutSection ValidateLayoutSectionName(string name) + { + var candidate = LayoutSection.From(name); + if (!LayoutSection.All.Contains(candidate)) + { + throw new InvalidOperationException( + $"Invalid layout name '{name}'. Allowed: {string.Join(", ", LayoutSection.All)}" + ); + } + + return candidate; + } + + private static LayoutDirection ParseDirection(string? raw, string? context) => + raw switch + { + "Rows" => LayoutDirection.Rows, + "Columns" => LayoutDirection.Columns, + _ => throw new InvalidOperationException( + $"Invalid SplitDirection '{raw}' in layout '{context ?? ""}'" + ), + }; +} + + +// File: src\Typical\TUI\Runtime\LayoutFactory.cs + +using Spectre.Console; +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public class LayoutFactory +{ + private readonly RuntimeLayoutDict _presets; + + public LayoutFactory(RuntimeLayoutDict presets) + { + _presets = presets; + } + + public Layout Build(LayoutName rootLayout) + { + if (!_presets.TryGetValue(rootLayout, out var rootDefinition)) + { + return new Layout(rootLayout.Value); + } + + return BuildLayoutFromDefinition(rootDefinition, rootLayout.Value); + } + + private Layout BuildLayoutFromDefinition(LayoutNode node, string name) + { + // Use root or child name + var layout = new Layout(name); + + layout.Ratio(node.Ratio); + + if (node.Children.Count == 0) + return layout; + + var childLayouts = node + .Children.Select(kvp => BuildLayoutFromDefinition(kvp.Value, kvp.Key.Value)) + .ToArray(); + + if (node.Direction == LayoutDirection.Rows) + layout.SplitRows(childLayouts); + else + layout.SplitColumns(childLayouts); + + return layout; + } +} + + +// File: src\Typical\TUI\Runtime\LayoutNode.cs + +namespace Typical.TUI.Settings; + +public record LayoutNode( + int Ratio, + LayoutDirection Direction, + Dictionary Children +); + + +// File: src\Typical\TUI\Runtime\ThemeConversion.cs + +using Typical.TUI.Settings; + +namespace Typical.TUI.Runtime; + +public static class ThemeConversion +{ + /// + /// Converts a string-keyed Theme dictionary into a strongly-typed ThemeSettings dictionary. + /// Validates that all keys are defined LayoutName values. + /// + public static RuntimeTheme ToRuntimeTheme(this Theme theme) + { + var result = new RuntimeTheme(); + + foreach (var kvp in theme) + { + var layoutName = ValidateLayoutSection(kvp.Key); + result[layoutName] = kvp.Value; + } + + return result; + } + + /// + /// Converts a ThemeDict (string-keyed themes) to a runtime dictionary keyed by theme name + /// with ThemeSettings as values. + /// + public static Dictionary ToRuntimeThemes(this ThemeDict themeDict) + { + return themeDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToRuntimeTheme()); + } + + private static LayoutSection ValidateLayoutSection(string name) + { + var candidate = LayoutSection.From(name); + if (!LayoutSection.All.Contains(candidate)) + { + throw new InvalidOperationException( + $"Invalid layout section '{name}' in Theme. Allowed values: {string.Join(", ", LayoutName.All)}" + ); + } + + return candidate; + } +} + +public class RuntimeTheme : Dictionary { } + +public class RuntimeThemeDict : Dictionary { } + +public class RuntimeLayoutDict : Dictionary +{ + public RuntimeLayoutDict() + : base() { } + + public RuntimeLayoutDict(Dictionary dictionary) + : base(dictionary) { } +} + + +// File: src\Typical\TUI\Settings\AlignmentSettings.cs + +namespace Typical.TUI.Settings; + +public class AlignmentSettings +{ + public VerticalAlignment Vertical { get; set; } + public HorizontalAlignment Horizontal { get; set; } +} + + +// File: src\Typical\TUI\Settings\AppSettings.cs + +namespace Typical.TUI.Settings; + +public class AppSettings +{ + public ThemeDict Themes { get; set; } = []; + public LayoutPresetDict Layouts { get; set; } = []; +} + + +// File: src\Typical\TUI\Settings\AppSettingsExtensions.cs + +using Typical.TUI.Runtime; + +namespace Typical.TUI.Settings; + +public static class AppSettingsExtensions +{ + public static RuntimeLayoutDict ToRuntimeLayouts(this LayoutPresetDict layoutDict) + { + return new RuntimeLayoutDict( + layoutDict.ToDictionary( + kvp => LayoutName.From(kvp.Key), + kvp => kvp.Value.ToRuntimeRoot() + ) + ); + } + + // Themes can stay string-keyed or convert similarly if needed +} + + +// File: src\Typical\TUI\Settings\BorderStyleSettings.cs + +namespace Typical.TUI.Settings; + +public class BorderStyleSettings +{ + public string? ForegroundColor { get; set; } + public string? Decoration { get; set; } +} + + +// File: src\Typical\TUI\Settings\ElementStyle.cs + +namespace Typical.TUI.Settings; + +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; +} + + +// File: src\Typical\TUI\Settings\LayoutDefinition.cs + +namespace Typical.TUI.Settings; + +public class LayoutDefinition +{ + public string Name { get; set; } = default!; + public int? Ratio { get; set; } = 1; + public string? SplitDirection { get; set; } = "Columns"; + public List Children { get; set; } = []; +} + + +// File: src\Typical\TUI\Settings\LayoutName.cs + +using System.Diagnostics.CodeAnalysis; +using Vogen; + +namespace Typical.TUI.Settings; + +[ValueObject(conversions: Conversions.SystemTextJson)] +public partial record LayoutName +{ + public static readonly LayoutName Default = From(nameof(Default)); + public static readonly LayoutName Dashboard = From(nameof(Dashboard)); + + public static readonly IReadOnlySet All = new HashSet + { + Default, + Dashboard, + }; +} + + +// File: src\Typical\TUI\Settings\LayoutSection.cs + +using Vogen; + +namespace Typical.TUI.Settings; + +[ValueObject(conversions: Conversions.SystemTextJson)] +public partial record LayoutSection +{ + public static readonly LayoutSection Default = From(nameof(Default)); + public static readonly LayoutSection Header = From(nameof(Header)); + public static readonly LayoutSection Breadcrumb = From(nameof(Breadcrumb)); + public static readonly LayoutSection TypingArea = From(nameof(TypingArea)); + public static readonly LayoutSection Footer = From(nameof(Footer)); + public static readonly LayoutSection GeneralInfo = From(nameof(GeneralInfo)); + public static readonly LayoutSection GameInfo = From(nameof(GameInfo)); + public static readonly LayoutSection TypingInfo = From(nameof(TypingInfo)); + public static readonly LayoutSection Center = From(nameof(Center)); + + public static readonly IReadOnlySet All = new HashSet + { + Default, + Header, + TypingArea, + Footer, + Breadcrumb, + GameInfo, + TypingInfo, + Center, + }; +} + + +// File: src\Typical\TUI\Settings\PanelHeaderSettings.cs + +namespace Typical.TUI.Settings; + +public class PanelHeaderSettings +{ + public string? Text { get; set; } +} + + +// File: src\Typical\TUI\Settings\ThemeManager.cs + +using Spectre.Console; +using Spectre.Console.Rendering; +using Typical.TUI.Runtime; + +namespace Typical.TUI.Settings; + +public class ThemeManager +{ + private RuntimeTheme _activeTheme; + private readonly Dictionary _themes; + + public string ActiveThemeName { get; private set; } + + public ThemeManager(Dictionary themes, string? defaultTheme = null) + { + if (themes?.Any() != true) + throw new ArgumentException("No themes provided."); + + _themes = themes; + + ActiveThemeName = defaultTheme ?? _themes.Keys.First(); + _activeTheme = _themes[ActiveThemeName]; + } + + public IRenderable Apply(T renderable, LayoutSection layoutName) + where T : IRenderable + { + if (!_activeTheme.TryGetValue(layoutName, out var style)) + { + _activeTheme.TryGetValue(LayoutSection.Default, out style); + } + + style ??= new ElementStyle(); + + if (layoutName == LayoutSection.Header) + style.WrapInPanel = false; + + if (!style.WrapInPanel) + return renderable; + + Panel finalPanel; + + if (renderable is Panel existingPanel) + { + finalPanel = existingPanel; + } + else + { + IRenderable content = renderable; + if (style.Alignment is not null) + { + 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.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); + + finalPanel.BorderStyle = new Style(foreground: foreground, decoration: decoration); + } + + if (style.PanelHeader?.Text is not null) + { + finalPanel.Header = new PanelHeader(style.PanelHeader.Text); + } + return finalPanel.Expand(); + } + + private static Color? ParseColor(string stringColor) + { + if (stringColor.StartsWith('#')) + return Color.FromHex(stringColor); + + return Enum.TryParse(stringColor, out var consoleColor) + ? (Color?)consoleColor + : null; + } + + public bool TrySetTheme(string themeName) + { + bool exists = _themes.TryGetValue(themeName, out var theme); + if (exists && theme is not null) + { + _activeTheme = theme; + } + + return exists && theme is not null; + } +} + + +// File: src\Typical\TUI\Settings\ThemeSettings.cs + +namespace Typical.TUI.Settings; + +public class Theme : Dictionary { } + +public class LayoutPresetDict : Dictionary; + +public class ThemeDict : Dictionary { } + + +// File: src\Typical\TUI\Views\GameView.cs + +using Spectre.Console; +using Spectre.Console.Rendering; +using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; + +namespace Typical.TUI.Views; + +public class GameView : IView +{ + private readonly MarkupGenerator _markupGenerator; + + private readonly GameEngine _engine; + private readonly ThemeManager _theme; + private readonly LayoutFactory _layoutFactory; + private readonly IAnsiConsole _console; + private string _targetText = string.Empty; + private string _userInput = string.Empty; + private GameStatisticsSnapshot _statistics = GameStatisticsSnapshot.Empty; + private bool _isGameOver; + private bool _needsRefresh; + + public GameView( + GameEngine engine, + ThemeManager theme, + MarkupGenerator markupGenerator, + LayoutFactory layoutFactory, + IEventAggregator eventAggregator, + IAnsiConsole console + ) + { + _engine = engine; + _theme = theme; + _markupGenerator = markupGenerator; + _layoutFactory = layoutFactory; + _console = console; + + eventAggregator.Subscribe(OnGameStateUpdated); + } + + private void OnGameStateUpdated(GameStateUpdatedEvent e) + { + // Cache the new state + _targetText = e.TargetText; + _userInput = e.UserInput; + _statistics = e.Statistics; + _isGameOver = e.IsOver; + + // Set a single flag to refresh the entire UI + _needsRefresh = true; + } + + public async Task RenderAsync() + { + var layout = _layoutFactory.Build(LayoutName.Dashboard); + await _console + .Live(layout) + .StartAsync(async ctx => + { + await _engine.StartNewGame(); + 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; + int lastWidth = Console.WindowWidth; + + while (!_isGameOver) + { + if (Console.WindowWidth != lastWidth || Console.WindowHeight != lastHeight) + { + lastWidth = Console.WindowWidth; + lastHeight = Console.WindowHeight; + _needsRefresh = true; + } + + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + if (!_engine.ProcessKeyPress(key)) + break; + } + + if (_needsRefresh) + { + layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea()); + layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea()); + ctx.Refresh(); + _needsRefresh = false; // Reset the flag + } + + if (_isGameOver) + { + ctx.Refresh(); + Thread.Sleep(500); + break; + } + + Thread.Sleep(_engine.TargetFrameDelayMilliseconds); + } + }); + + DisplaySummary(); + } + + private IRenderable CreateGameInfoArea() + { + // Use the cached statistics object + if (_statistics is null) + return new Text(""); + + var grid = new Grid(); + grid.AddColumns([new GridColumn(), new GridColumn()]); + grid.AddRow("WPM:", $"{_statistics.WordsPerMinute:F1}"); + grid.AddRow("Accuracy:", $"{_statistics.Accuracy:F1}%"); + grid.AddRow("Correct Chars:", $"{_statistics.Chars.Correct}"); + grid.AddRow("Incorrect Chars:", $"{_statistics.Chars.Incorrect}"); + grid.AddRow("Extra Chars:", $"{_statistics.Chars.Extra}"); + grid.AddRow("Elapsed:", $"{_statistics.ElapsedTime:mm\\:ss}"); + return _theme.Apply(grid, LayoutSection.GameInfo); + } + + private IRenderable CreateTypingArea() + { + // Use the cached text fields + var markup = _markupGenerator.BuildMarkupOptimized(_targetText, _userInput); + return _theme.Apply(markup, LayoutSection.TypingArea); + } + + private IRenderable CreateHeader() + { + return _theme.Apply(new Markup("Typical - A Typing Tutor"), LayoutSection.Header); + } + + private Action DisplaySummary() => + summaryString => AnsiConsole.MarkupLineInterpolated($"[bold green]{summaryString}[/]"); +} + + +// File: src\Typical\TUI\Views\IView.cs + +namespace Typical.TUI.Views; + +public interface IView +{ + // Renders the content of the view. + Task RenderAsync(); +} + +public class StatsView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} + +public class MainMenuView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} + + +// File: src\Typical\TUI\AppShell.cs + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI.Views; + +namespace Typical.TUI; + +public class AppShell +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AppShell(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task RunAsync() + { + AppLogs.ApplicationStarting(_logger); + IView currentView = _serviceProvider.GetRequiredService(); + + if (currentView != null) + { + await currentView.RenderAsync(); + + // The view's RenderAsync method would return the next view to transition to, + // or null to quit. + // e.g., MainMenuView returns a new GameView when the user selects "Start". + // currentView = await currentView.GetNextViewAsync(); + } + AppLogs.ApplicationStopping(_logger); + } +} + + +// File: src\Typical\ApplicationCommands.cs + +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI; +using Typical.TUI.Views; + +namespace Typical; + +// The [Command] attribute on the class is optional but good practice. +public class ApplicationCommands +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + // The DI container will inject the services we need here. + public ApplicationCommands( + IServiceProvider serviceProvider, + ILogger logger + ) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// The entry point for interactive mode (when no arguments are given). + /// + [Command("")] + public async Task RunInteractive() + { + AppLogs.NoCommandsInteractive(_logger); + + // Resolve the AppShell from the DI container and run it. + var appShell = _serviceProvider.GetRequiredService(); + await appShell.RunAsync(); + } + + /// + /// Directly starts a typing game, bypassing the main menu. + /// + [Command("play")] + public async Task Play(string mode = "Quote", int duration = 60) + { + AppLogs.StartingGame(_logger, mode, duration); + + // Resolve a GameView directly from the DI container. + // This is a "one-shot" game session. + var gameView = _serviceProvider.GetRequiredService(); + + // We would need to pass these options to the GameView to configure the game. + // For example: await gameView.RunAsync(new GameOptions { Mode = mode, Duration = duration }); + await gameView.RenderAsync(); // Simplified for this example + } + + /// + /// Displays user statistics directly. + /// + [Command("stats")] + public async Task ShowStats() + { + _logger.LogInformation("Displaying stats view."); + var statsView = _serviceProvider.GetRequiredService(); + await statsView.RenderAsync(); + } +} + + +// File: src\Typical\ConfigurationExtensions.cs + +// using Microsoft.Extensions.Configuration; +// using Typical.TUI; +// using Typical.TUI.Settings; + +// namespace Typical; + +// public static class ConfigurationExtensions +// { +// public static RuntimeTheme GetThemeSettings(this IConfiguration configuration) +// { +// var section = configuration.GetSection("Theme"); +// var dict = new RuntimeTheme(); + +// foreach (var child in section.GetChildren()) +// { +// var key = LayoutName.From(child.Key); +// var value = child.Get(); +// dict[key] = value; +// } + +// return dict; +// } + +// // TODO:Not working +// public static LayoutPresetDict GetLayoutPresets(this IConfiguration configuration) +// { +// var section = configuration.GetSection("Layouts"); +// var dict = new LayoutPresetDict(); + +// foreach (var child in section.GetChildren()) +// { +// var key = child.Key; +// var value = child.Get(); +// dict[key] = value; +// } + +// return dict; +// } +// } + + +// File: src\Typical\MarkupGenerator.cs + +using System.Text; +using Spectre.Console; + +namespace Typical; + +public class MarkupGenerator +{ + public Markup BuildMarkupOptimized(string target, string typed) + { + return new Markup(BuildMarkupString(target, typed)); + } + + internal string BuildMarkupString(string target, string typed) + { + if (string.IsNullOrEmpty(target)) + { + return string.Empty; + } + + var builder = new StringBuilder(); + var typedLength = typed.Length; + TypingResult currentState = TypingResult.Untyped; + + if (typedLength > 0 && target.Length > 0) + { + currentState = target[0] == typed[0] ? TypingResult.Correct : TypingResult.Incorrect; + } + builder.Append(GetMarkupForState(currentState)); + + for (int i = 0; i < target.Length; i++) + { + TypingResult charState; + if (i >= typedLength) + { + charState = TypingResult.Untyped; + } + else + { + charState = target[i] == typed[i] ? TypingResult.Correct : TypingResult.Incorrect; + } + + if (charState != currentState) + { + builder.Append("[/]"); + builder.Append(GetMarkupForState(charState)); + currentState = charState; + } + var escapedChar = Markup.Escape(target[i].ToString()); + + if (i == typedLength) + { + builder.Append($"[underline]{escapedChar}[/]"); + } + else + { + builder.Append(escapedChar); + } + } + + builder.Append("[/]"); + + if (typedLength > target.Length) + { + builder.Append(GetMarkupForState(TypingResult.Incorrect)); + builder.Append($"{Markup.Escape(typed.Substring(target.Length))}"); + builder.Append("[/]"); + } + + return builder.ToString(); + } + + private string GetMarkupForState(TypingResult state) => + state switch + { + TypingResult.Correct => "[default on green]", + TypingResult.Incorrect => "[red on grey15]", + _ => "[grey]", + }; +} + + +// File: src\Typical\Program.cs + +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Typical; +using Typical.Services; +using Velopack; + +VelopackApp.Build().Run(); + +var services = new ServiceCollection(); + +services.RegisterAppServices(); + +ConsoleApp.ServiceProvider = services.BuildServiceProvider(); + +var app = ConsoleApp.Create(); + +app.Add(); + +await app.RunAsync(args); + + +// File: src\Typical\StaticTextProvider.cs + +using Typical.Core.Text; + +namespace Typical; + +internal class StaticTextProvider(string text) : ITextProvider +{ + private readonly string _text = text; + + public Task GetTextAsync() => Task.FromResult(_text); +} + + +// File: src\Typical\TypingResult.cs + +namespace Typical; + +internal enum TypingResult +{ + Untyped, + Correct, + Incorrect, +} + + +// File: src\Typical.Core\Events\BackspacePressedEvent.cs + +namespace Typical.Core.Events; + +internal record BackspacePressedEvent; + + +// File: src\Typical.Core\Events\GameEndedEvent.cs + +namespace Typical.Core.Events; + +public record GameEndedEvent; + + +// File: src\Typical.Core\Events\GameQuitEvent.cs + +namespace Typical.Core.Events; + +public record GameQuitEvent; + + +// File: src\Typical.Core\Events\GameStateUpdatedEvent.cs + +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +public record GameStateUpdatedEvent( + string TargetText, + string UserInput, + GameStatisticsSnapshot Statistics, + bool IsOver +); + + +// File: src\Typical.Core\Events\IEventAggregator.cs + +using System.Reflection.Metadata; + +namespace Typical.Core.Events; + +public interface IEventAggregator +{ + void Subscribe(Action handler) + where TEvent : class; + void Unsubscribe(Action handler) + where TEvent : class; + void Publish(TEvent eventToPublish) + where TEvent : class; +} + +public class EventAggregator : IEventAggregator +{ + private readonly Dictionary> _handlers = []; + private readonly Lock _aggregatorLock = new(); + + public void Subscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out List? value)) + { + value = []; + _handlers[eventType] = value; + } + + value.Add(handler); + } + } + + public void Unsubscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + lock (_aggregatorLock) + { + if (_handlers.TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers.Remove(handler); + + if (eventHandlers.Count == 0) + { + _handlers.Remove(eventType); + } + } + } + } + + public void Publish(TEvent eventToPublish) + where TEvent : class + { + var eventType = typeof(TEvent); + List handlersSnapshot; + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out var eventHandlers)) + { + return; + } + + handlersSnapshot = eventHandlers.ToList(); + } + + foreach (var handler in handlersSnapshot) + { + ((Action)handler)(eventToPublish); + } + } +} + + +// File: src\Typical.Core\Events\KeyPressedEvent.cs + +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +internal record KeyPressedEvent(char Character, KeystrokeType Type, int Position); + + +// File: src\Typical.Core\Statistics\CharacterStats.cs + +namespace Typical.Core.Statistics; + +// A simple record to hold the results of GetCharacterStats +public record CharacterStats(int Correct, int Incorrect, int Extra, int Corrections); + + +// File: src\Typical.Core\Statistics\GameStatisticsSnapshot.cs + +namespace Typical.Core.Statistics; + +public record GameStatisticsSnapshot( + double WordsPerMinute, + double Accuracy, + CharacterStats Chars, + TimeSpan ElapsedTime, + bool IsRunning +) +{ + public static GameStatisticsSnapshot Empty => + new(0, 100, new CharacterStats(0, 0, 0, 0), TimeSpan.Zero, false); +} + + +// File: src\Typical.Core\Statistics\GameStats.cs + +using Typical.Core.Events; + +namespace Typical.Core.Statistics; + +internal class GameStats +{ + private readonly IEventAggregator _eventAggregator; + private readonly TimeProvider _timeProvider; + private readonly KeystrokeHistory _keystrokeHistory = []; + private long? _startTimestamp; + private long? _endTimestamp; + private bool _statsAreDirty = true; + private double _cachedWpm; + private double _cachedAccuracy; + private CharacterStats _cachedChars = new(0, 0, 0, 0); + + public GameStats(IEventAggregator eventAggregator, TimeProvider? timeProvider = null) + { + _eventAggregator = eventAggregator; + _timeProvider = timeProvider ?? TimeProvider.System; + _eventAggregator.Subscribe(OnKeyPressed); + _eventAggregator.Subscribe(OnBackspacePressed); + } + + private void OnBackspacePressed(BackspacePressedEvent @event) + { + if (!IsRunning) + { + return; + } + + _keystrokeHistory.RemoveLastCharacterLog(); + _keystrokeHistory.Add( + new KeystrokeLog('\b', KeystrokeType.Correction, _timeProvider.GetTimestamp()) + ); + + _statsAreDirty = true; + } + + private void OnKeyPressed(KeyPressedEvent @event) + { + if (!IsRunning) + { + Start(); + } + + _keystrokeHistory.Add( + new KeystrokeLog(@event.Character, @event.Type, _timeProvider.GetTimestamp()) + ); + _statsAreDirty = true; + } + + 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, 0); + } + + public void Stop() + { + if (IsRunning) + { + _endTimestamp = _timeProvider.GetTimestamp(); + } + } + + public GameStatisticsSnapshot CreateSnapshot() + { + if (_statsAreDirty) + { + RecalculateAllStats(); + } + + return new GameStatisticsSnapshot( + WordsPerMinute: _cachedWpm, + Accuracy: _cachedAccuracy, + Chars: _cachedChars, + ElapsedTime: this.ElapsedTime, + IsRunning: this.IsRunning + ); + } + + private void RecalculateAllStats() + { + _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime); + _cachedAccuracy = _keystrokeHistory.CalculateAccuracy(); + _cachedChars = _keystrokeHistory.GetCharacterStats(); + + _statsAreDirty = false; + } +} + + +// File: src\Typical.Core\Statistics\KeystrokeHistory.cs + +using System.Collections; + +namespace Typical.Core.Statistics; + +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, int Corrections) GetCounts() + { + int correct = 0; + int incorrect = 0; + int extra = 0; + int corrections = 0; + + foreach (var log in _logs) + { + switch (log.Type) + { + case KeystrokeType.Correct: + correct++; + break; + case KeystrokeType.Incorrect: + incorrect++; + break; + case KeystrokeType.Extra: + extra++; + break; + case KeystrokeType.Correction: + corrections++; + break; + } + } + return (correct, incorrect, extra, corrections); + } + + 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, + Corrections: counts.Corrections + ); + } + + public IEnumerator GetEnumerator() => _logs.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void RemoveLastCharacterLog() + { + // Use FindLastIndex to search from the end of the list. + int indexToRemove = _logs.FindLastIndex(log => + log.Type == KeystrokeType.Correct + || log.Type == KeystrokeType.Incorrect + || log.Type == KeystrokeType.Extra + ); + + // If a log was found (index is not -1), remove it. + if (indexToRemove != -1) + { + _logs.RemoveAt(indexToRemove); + } + } +} + + +// File: src\Typical.Core\Statistics\KeystrokeLog.cs + +namespace Typical.Core.Statistics; + +public record struct KeystrokeLog(char Character, KeystrokeType Type, long Timestamp); + + +// File: src\Typical.Core\Statistics\KeystrokeType.cs + +namespace Typical.Core.Statistics; + +public enum KeystrokeType +{ + Correct, + Incorrect, + Extra, + Correction, +} + + +// File: src\Typical.Core\Text\ITextProvider.cs + +namespace Typical.Core.Text; + +public interface ITextProvider +{ + Task GetTextAsync(); +} + + +// File: src\Typical.Core\GameEngine.cs + +using System.Text; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class GameEngine +{ + private readonly StringBuilder _userInput; + private readonly ITextProvider _textProvider; + private readonly GameOptions _gameOptions; + private readonly IEventAggregator _eventAggregator; + private readonly GameStats _stats; + + public GameEngine(ITextProvider textProvider, IEventAggregator eventAggregator) + : this(textProvider, eventAggregator, new GameOptions()) { } + + public GameEngine( + ITextProvider textProvider, + IEventAggregator eventAggregator, + GameOptions gameOptions + ) + { + _textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider)); + _gameOptions = gameOptions; + _userInput = new StringBuilder(); + _eventAggregator = eventAggregator; + + _stats = new GameStats(_eventAggregator); + } + + 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 bool ProcessKeyPress(ConsoleKeyInfo key) + { + if (key.Key == ConsoleKey.Escape) + { + IsOver = true; + _stats.Stop(); + _eventAggregator.Publish(new GameQuitEvent()); + return false; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (_userInput.Length > 0) + { + _userInput.Remove(_userInput.Length - 1, 1); + _eventAggregator.Publish(new BackspacePressedEvent()); + PublishStateUpdate(); + } + return true; + } + + if (char.IsControl(key.KeyChar)) + { + return true; + } + char inputChar = key.KeyChar; + KeystrokeType type = DetermineKeystrokeType(inputChar); + + _eventAggregator.Publish(new KeyPressedEvent(inputChar, type, _userInput.Length)); + + bool isCorrect = type == KeystrokeType.Correct; + if (!_gameOptions.ForbidIncorrectEntries || isCorrect) + { + _userInput.Append(key.KeyChar); + } + + CheckEndCondition(); + PublishStateUpdate(); + + return true; + } + + private KeystrokeType DetermineKeystrokeType(char inputChar) + { + int currentPos = _userInput.Length; + + if (currentPos >= TargetText.Length) + { + return KeystrokeType.Extra; + } + + if (inputChar == TargetText[currentPos]) + { + return KeystrokeType.Correct; + } + + return KeystrokeType.Incorrect; + } + + private void CheckEndCondition() + { + if (_userInput.ToString() == TargetText) + { + IsOver = true; + _stats.Stop(); + + _eventAggregator.Publish(new GameEndedEvent()); + } + } + + public async Task StartNewGame() + { + TargetText = await _textProvider.GetTextAsync(); + _stats.Start(); + _userInput.Clear(); + IsOver = false; + PublishStateUpdate(); + } + + private void PublishStateUpdate() + { + var snapShot = _stats.CreateSnapshot(); + var stateEvent = new GameStateUpdatedEvent(TargetText, UserInput, snapShot, IsOver); + _eventAggregator.Publish(stateEvent); + } +} + + +// File: src\Typical.Core\GameOptions.cs + +namespace Typical.Core; + +public record GameOptions +{ + public static GameOptions Default { get; set; } = new(); + public bool ForbidIncorrectEntries { get; set; } = false; + public int TargetFrameRate { get; set; } = 60; + // Future options could be added here: + // public int TimeLimitSeconds { get; set; } = 0; // 0 for no limit + // public bool ShowLiveWpm { get; set; } = false; +} + + +// File: src\Typical.Tests\Core\GameStatsTests.cs + +using System; +using Microsoft.Extensions.Time.Testing; +using TUnit; +using Typical.Core.Statistics; + +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); + } + } +} + + +// File: src\Typical.Tests\TUI\LayoutFactoryTests.cs + +// using Spectre.Console; +// using Spectre.Console.Rendering; +// using Typical.TUI.Runtime; +// using Typical.TUI.Settings; + +// namespace Typical.Tests.TUI; + +// public class LayoutFactoryTests +// { +// private readonly IRenderable _testRenderable = new Text("Test Content"); + +// [Test] +// public async Task Constructor_WhenGivenNullConfiguration_DoesNotThrow() +// { +// // Arrange & Act +// var factoryAction = () => new LayoutFactory(null!); + +// // Assert +// await Assert.That(factoryAction.Invoke).ThrowsNothing(); +// } + +// [Test] +// public async Task GetContentFor_WhenContentExistsInConfiguration_ReturnsLayoutWithCorrectNameAndRenderable() +// { +// // Arrange +// var config = new RuntimeLayoutDict(); +// var factory = new LayoutFactory(config); + +// // Act +// var resultLayout = factory.GetContentFor(LayoutSection.Header); + +// // Assert +// await Assert.That(resultLayout).IsNotNull(); +// await Assert.That(LayoutSection.Header.Value).IsEqualTo(resultLayout.Name); +// // await Assert.That(_testRenderable, resultLayout.Renderable).AreSame(); +// } + +// [Test] +// public async Task GetContentFor_WhenContentDoesNotExistInConfiguration_ReturnsLayoutWithCorrectNameAndNullRenderable() +// { +// // Arrange +// var config = LayoutConfiguration.Default; +// var factory = new LayoutFactory(config); + +// // Act +// var resultLayout = factory.GetContentFor(LayoutSection.Footer); + +// // Assert +// await Assert.That(resultLayout).IsNotNull(); +// await Assert.That(LayoutSection.Footer.Value).IsEqualTo(resultLayout.Name); +// // await Assert.That(resultLayout.Renderable).IsNull(); // TODO: Use IAnsiConsole TestConsole +// } + +// [Test] +// public async Task BuildClassicFocus_WithEmptyConfiguration_BuildsSuccessfully() +// { +// // Arrange +// var factory = new LayoutFactory(LayoutConfiguration.Default); + +// // Act +// var layout = factory.Build(LayoutName.Dashboard); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(LayoutName.Dashboard.Value).IsEqualTo(layout.Name); +// } + +// [Test] +// public async Task Build_WhenRootLayoutNotInPresets_ReturnsEmptyRootLayout() +// { +// // Arrange +// var factory = new LayoutFactory(new RuntimeLayoutDict()); + +// // Act +// var layout = factory.Build(LayoutName.Root); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(layout.Name).IsEqualTo(LayoutName.Root.Value); +// await Assert.That(layout.Children).IsEmpty(); +// } + +// [Test] +// public async Task Build_ReturnsRootLayout() +// { +// // Arrange +// var factory = new LayoutFactory(); + +// // Act +// var layout = factory.Build(); + +// // Assert +// await Assert.That(layout).IsNotNull(); +// await Assert.That(LayoutSection.Root.Value).IsEqualTo(layout.Name); +// // await Assert.That(layout.Renderable).IsNull(); // TODO: Use IAnsiConsole TestConsole +// } +// } + + +// File: src\Typical.Tests\TUI\ThemeSettingsBindingTests.cs + +// using System.Collections.Generic; +// using System.ComponentModel; +// using System.Threading.Tasks; +// using Microsoft.Extensions.Configuration; +// using TUnit; +// using Typical.TUI.Runtime; +// using Typical.TUI.Settings; + +// namespace Typical.TUI.Tests; + +// public class ThemeSettingsBindingTests +// { +// [Test] +// public async Task Can_Bind_ThemeSettings_With_LayoutName_Keys() +// { +// // Arrange: fake config JSON in memory +// var json = +// @" +// { +// ""Theme"": { +// ""Header"": { +// ""PanelHeader"": { ""Text"": ""HeaderText"" } +// }, +// ""TypingArea"": { +// ""PanelHeader"": { ""Text"": ""TypingText"" } +// } +// } +// }"; + +// var configuration = new ConfigurationBuilder() +// .AddJsonStream(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))) +// .Build(); + +// // Act: bind into our strongly-typed ThemeSettings +// var themeSettings = configuration.GetSection("Theme").Get(); + +// // Assert: dictionary has LayoutName keys, not strings +// await Assert.That(themeSettings).IsNotNull(); +// await Assert.That(themeSettings!.ContainsKey(LayoutSection.Header)).IsTrue(); +// await Assert.That(themeSettings!.ContainsKey(LayoutSection.TypingArea)).IsTrue(); + +// // And check values came through +// await Assert +// .That(themeSettings[LayoutSection.Header].PanelHeader?.Text) +// .IsEqualTo("HeaderText"); +// await Assert +// .That(themeSettings[LayoutSection.TypingArea].PanelHeader?.Text) +// .IsEqualTo("TypingText"); +// } +// } + + +// File: src\Typical.Tests\TUI\ThemeTests.cs + +using Spectre.Console; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; + +public class ThemeTests +{ + // --- Basic Styling Tests --- + + [Test] + public async Task Apply_WithSpecificStyle_SetsPanelBorderStyle() + { + // Arrange + var theme = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "Blue" }, + } + }, + }; + var themeDict = new Dictionary { { "default", theme } }; + var manager = new ThemeManager(themeDict); + var layoutName = LayoutSection.From("TestArea"); + + // Act + var panel = new Panel(""); + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle).IsNotNull(); + await Assert.That(panel.BorderStyle!.Foreground).IsEqualTo(Color.Blue); + } + + [Test] + public async Task Apply_WithSpecificStyle_SetsPanelHeader() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle { PanelHeader = new PanelHeaderSettings { Text = "Hello" } } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + var manager = new ThemeManager(themeDict); + + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.Header).IsNotNull(); + await Assert.That(panel.Header!.Text).IsEqualTo("Hello"); + } + + [Test] + public async Task Apply_WithHexColor_CorrectlyParsesAndSetsColor() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "#FF00FF" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Foreground).IsEqualTo(new Color(255, 0, 255)); + } + + [Test] + public async Task Apply_WithDecoration_CorrectlyParsesAndSetsDecoration() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { Decoration = "Underline" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Decoration).IsEqualTo(Decoration.Underline); + } + + // --- Fallback and Edge Case Tests --- + + [Test] + public async Task Apply_WhenStyleIsMissing_FallsBackToDefaultStyle() + { + // Arrange + var settings = new RuntimeTheme + { + // Note: "TestArea" is missing, but "Default" is present. + { + LayoutSection.From("Default"), + new ElementStyle + { + BorderStyle = new BorderStyleSettings { ForegroundColor = "Red" }, + } + }, + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); // Requesting a style that doesn't exist + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle?.Foreground).IsEqualTo(Color.Red); + } + + [Test] + public async Task Apply_WhenNoSpecificOrDefaultStyle_DoesNotChangePanel() + { + // Arrange + var settings = new RuntimeTheme(); // Completely empty settings + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var originalPanel = new Panel(""); + // Manually set a border to ensure it doesn't get overwritten + originalPanel.BorderStyle = new Style(Color.Green); + + // Act + manager.Apply(originalPanel, LayoutSection.From("NonExistent")); + + // Assert + // The panel's style should be unchanged from its original state. + await Assert.That(originalPanel.BorderStyle?.Foreground).IsEqualTo(Color.Green); + await Assert.That(originalPanel.Header).IsNull(); + } + + [Test] + public async Task Apply_WithOnlyPartialStyleInfo_AppliesOnlyWhatIsProvided() + { + // Arrange + var settings = new RuntimeTheme + { + { + LayoutSection.From("TestArea"), + new ElementStyle { BorderStyle = new BorderStyleSettings { Decoration = "Bold" } } + }, + // Note: ForegroundColor and PanelHeader are missing from the config. + }; + var themeDict = new Dictionary { { "default", settings } }; + + var manager = new ThemeManager(themeDict); + var panel = new Panel(""); + var layoutName = LayoutSection.From("TestArea"); + + // Act + manager.Apply(panel, layoutName); + + // Assert + await Assert.That(panel.BorderStyle).IsNotNull(); + // Foreground should be the default, not null. + await Assert.That(panel.BorderStyle!.Foreground).IsEqualTo(Color.Default); + await Assert.That(panel.BorderStyle.Decoration).IsEqualTo(Decoration.Bold); + await Assert.That(panel.Header).IsNull(); // Header should not have been set. + } + + // NOTE: The `Alignment` properties are not directly testable on the `Panel` itself, + // because the `Apply` method returns a *new wrapper object* (`Align`) when alignment is set. + // Testing this would require checking the type of the returned object, which is + // more complex and often considered an implementation detail. For now, testing the + // direct mutations of the panel provides excellent coverage of the core logic. +} + + +// File: src\Typical.Tests\GameEngineTests.cs + +using Typical.Core; + +namespace Typical.Tests; + +public class TypicalGameTests +{ + private readonly MockTextProvider _mockTextProvider; + private readonly GameOptions _defaultOptions; + private readonly GameOptions _strictOptions; + + public TypicalGameTests() + { + // This runs before each test, ensuring a clean state. + _mockTextProvider = new MockTextProvider(); + _defaultOptions = new GameOptions(); + _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; + } + + // --- StartNewGame Tests --- + + [Test] + public async Task StartNewGame_Always_LoadsTextFromProvider() + { + // Arrange + var expectedText = "This is a test."; + _mockTextProvider.SetText(expectedText); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + + // Act + await game.StartNewGame(); + + // Assert + await Assert.That(game.TargetText).IsEqualTo(expectedText); + } + + [Test] + public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() + { + // Arrange + _mockTextProvider.SetText("some text"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + + // Simulate playing the game + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + game.ProcessKeyPress( + new ConsoleKeyInfo((char)ConsoleKey.Escape, ConsoleKey.Escape, false, false, false) + ); + await Assert.That(game.IsOver).IsTrue(); + await Assert.That(game.UserInput).IsNotEmpty(); + + // Act + _mockTextProvider.SetText("new text"); + await game.StartNewGame(); + + // Assert + await Assert.That(game.IsOver).IsFalse(); + await Assert.That(game.UserInput).IsEmpty(); + await Assert.That(game.TargetText).IsEqualTo("new text"); + } + + // --- ProcessKeyPress Tests --- + + [Test] + public async Task ProcessKeyPress_EscapeKey_EndsGameAndReturnsFalse() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + + // Act + var result = game.ProcessKeyPress( + new ConsoleKeyInfo((char)ConsoleKey.Escape, ConsoleKey.Escape, false, false, false) + ); + + // Assert + await Assert.That(result).IsFalse(); + await Assert.That(game.IsOver).IsTrue(); + } + + [Test] + public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + game.ProcessKeyPress(new ConsoleKeyInfo('b', ConsoleKey.B, false, false, false)); + await Assert.That(game.UserInput).IsEqualTo("ab"); + + // Act + game.ProcessKeyPress( + new ConsoleKeyInfo( + (char)ConsoleKey.Backspace, + ConsoleKey.Backspace, + false, + false, + false + ) + ); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() + { + // Arrange + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress( + new ConsoleKeyInfo( + (char)ConsoleKey.Backspace, + ConsoleKey.Backspace, + false, + false, + false + ) + ); + + // Assert + await Assert.That(game.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() + { + // Arrange + _mockTextProvider.SetText("hi"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('h', ConsoleKey.H, false, false, false)); + game.ProcessKeyPress(new ConsoleKeyInfo('i', ConsoleKey.I, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("hi"); + await Assert.That(game.IsOver).IsTrue(); + } + + // --- GameOptions: ForbidIncorrectEntries Tests --- + + [Test] + public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _strictOptions); + await game.StartNewGame(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("a"); + } + + [Test] + public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _strictOptions); + await game.StartNewGame(); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEmpty(); + } + + [Test] + public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter() + { + // Arrange + _mockTextProvider.SetText("abc"); + var game = new GameEngine(_mockTextProvider, _defaultOptions); + await game.StartNewGame(); + await Assert.That(game.UserInput).IsEmpty(); + + // Act + game.ProcessKeyPress(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false)); + + // Assert + await Assert.That(game.UserInput).IsEqualTo("x"); + } +} + + +// File: src\Typical.Tests\MarkupGeneratorTests.cs + +using Typical; // Your project's namespace + +public class MarkupGeneratorTests +{ + private readonly MarkupGenerator _generator; + + public MarkupGeneratorTests() + { + // Create a new instance for each test to ensure isolation. + _generator = new MarkupGenerator(); + } + + // --- Core Scenarios --- + + [Test] + public async Task BuildMarkupOptimized_AllCorrectlyTyped_ReturnsFullyCorrectMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hello world"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[default on green]Hello world[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_PartiallyTypedAndCorrect_ReturnsCorrectAndUntypedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hello"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert + .That(result) + .IsEqualTo("[default on green]Hello[/][grey][underline] [/]world[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_WithErrors_ReturnsMixedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = "Hellx worlb"; // Two errors + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // "Hell" is correct, "o" is incorrect, " worl" is correct, "d" is incorrect. + var expected = + "[default on green]Hell[/][red on grey15]o[/][default on green] worl[/][red on grey15]d[/]"; + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task BuildMarkupOptimized_NothingTyped_ReturnsFullyUntypedMarkup() + { + // Arrange + var target = "Hello world"; + var typed = ""; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[grey][underline]H[/]ello world[/]"); + } + + // --- Edge Cases --- + + [Test] + public async Task BuildMarkupOptimized_EmptyTarget_ReturnsEmptyMarkup() + { + // Arrange + var target = ""; + var typed = "some input"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo(string.Empty); + } + + [Test] + public async Task BuildMarkupOptimized_UserTypedExtraCharacters_ShowsExtraCharsAsIncorrect() + { + // Arrange + var target = "Hello"; + var typed = "Hello world"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // "Hello" is correct, " world" is the extra incorrect part. + var expected = "[default on green]Hello[/][red on grey15] world[/]"; + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task BuildMarkupOptimized_AllCharactersIncorrect_ReturnsFullyIncorrectMarkup() + { + // Arrange + var target = "abcde"; + var typed = "fghij"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + await Assert.That(result).IsEqualTo("[red on grey15]abcde[/]"); + } + + [Test] + public async Task BuildMarkupOptimized_TargetContainsMarkupCharacters_EscapesThemCorrectly() + { + // Arrange + var target = "[[Hello]]"; + var typed = "[[Hello]]"; + + // Act + var result = _generator.BuildMarkupString(target, typed); + + // Assert + // The generator should escape the brackets so Spectre.Console doesn't interpret them. + await Assert.That(result).IsEqualTo("[default on green][[[[Hello]]]][/]"); + } +} + + +// File: src\Typical.Tests\MockTextProvider.cs + +using Typical.Core.Text; + +namespace Typical.Tests; + +public class MockTextProvider : ITextProvider +{ + private string _textToReturn = string.Empty; + + public void SetText(string text) + { + _textToReturn = text; + } + + public Task GetTextAsync() + { + // Task.FromResult is the perfect way to simulate an + // async operation that completes immediately. + return Task.FromResult(_textToReturn); + } +} + diff --git a/.github/instructions/codebase_analysis.md b/.github/instructions/codebase_analysis.md new file mode 100644 index 0000000..099a576 --- /dev/null +++ b/.github/instructions/codebase_analysis.md @@ -0,0 +1,351 @@ +# Codebase Analysis and Improvement Recommendations for Typical + +## Overview + +The Typical project is a console-based typing tutor application built with C# and Spectre.Console. It features a modular architecture with separate projects for core logic, TUI (Text User Interface), and tests. The application uses events for game state management, which is a good foundation for event-driven design. + +## Current Event-Driven Architecture + +The codebase has basic event support in the `GameEngine` class: + +- `GameEnded` event: Fired when the game completes +- `StateChanged` event: Fired when user input changes + +These events are consumed by `TypicalGame` for UI updates. This is a solid start, but the event system can be significantly expanded to improve maintainability, testability, and extensibility. + +## Recommended Improvements + +### 1. Expand Event-Driven Architecture + +#### Additional Game Events + +Introduce more granular events to decouple game logic from UI concerns: + +```csharp +// In GameEngine.cs +public event EventHandler? KeyPressed; +public event EventHandler? BackspacePressed; +public event EventHandler? GameStarted; +public event EventHandler? StatsUpdated; +public event EventHandler? GamePaused; +public event EventHandler? GameResumed; + +// Event args classes +public class KeyPressedEventArgs : EventArgs +{ + public char Character { get; } + public KeystrokeType Type { get; } + public int Position { get; } +} + +public class StatsUpdatedEventArgs : EventArgs +{ + public GameStatisticsSnapshot Stats { get; } +} + +public class GameStartedEventArgs : EventArgs +{ + public string TargetText { get; } +} +``` + +#### Event Aggregator Pattern + +Implement an event aggregator to reduce coupling between components: + +```csharp +public interface IEventAggregator +{ + void Publish(TEvent @event) where TEvent : class; + void Subscribe(Action handler) where TEvent : class; + void Unsubscribe(Action handler) where TEvent : class; +} + +public class EventAggregator : IEventAggregator +{ + private readonly Dictionary> _handlers = new(); + + // Implementation... +} +``` + +#### Async Event Handling + +Support async event handlers for better performance: + +```csharp +public event Func? KeyPressedAsync; +``` + +### 2. UI State Management Improvements + +#### Reactive UI Updates + +Replace polling-based updates with event-driven rendering: + +```csharp +// In TypicalGame.cs +private async Task HandleStateChanged(object? sender, GameStateChangedEventArgs e) +{ + await UpdateTypingAreaAsync(); + await UpdateStatsAreaAsync(); +} + +private async Task HandleStatsUpdated(object? sender, StatsUpdatedEventArgs e) +{ + await UpdateStatsAreaAsync(); +} +``` + +#### UI Event Bus + +Create a dedicated UI event system: + +```csharp +public interface IUiEventBus +{ + Task PublishAsync(TEvent @event) where TEvent : class; + void Subscribe(Func handler) where TEvent : class; +} +``` + +### 3. Configuration and Settings Enhancements + +#### Dynamic Configuration Reloading + +Add support for runtime configuration changes: + +```csharp +public interface IConfigurationService +{ + Task ReloadConfigurationAsync(); + event EventHandler? ConfigurationChanged; +} +``` + +#### Theme Switching Events + +Make theme changes event-driven: + +```csharp +// In ThemeManager.cs +public event EventHandler? ThemeChanged; + +public async Task TrySetThemeAsync(string themeName) +{ + // ... existing logic ... + if (success) + { + ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(themeName, _activeTheme)); + } + return success; +} +``` + +### 4. Text Provider Enhancements + +#### Text Loading Events + +Add events for text provider lifecycle: + +```csharp +public interface ITextProvider +{ + Task GetTextAsync(); + event EventHandler? TextLoading; + event EventHandler? TextLoaded; + event EventHandler? TextLoadError; +} +``` + +#### Multiple Text Sources + +Support for different text sources with events: + +```csharp +public class CompositeTextProvider : ITextProvider +{ + private readonly IEnumerable _providers; + + public async Task GetTextAsync() + { + foreach (var provider in _providers) + { + try + { + var text = await provider.GetTextAsync(); + TextLoaded?.Invoke(this, new TextLoadedEventArgs(text, provider.GetType().Name)); + return text; + } + catch (Exception ex) + { + TextLoadError?.Invoke(this, new TextLoadErrorEventArgs(ex, provider.GetType().Name)); + } + } + throw new InvalidOperationException("No text providers available"); + } + + // Events... +} +``` + +### 5. Statistics and Analytics + +#### Real-time Statistics Events + +Enhance statistics with more granular events: + +```csharp +// In GameStats.cs +public event EventHandler? WpmUpdated; +public event EventHandler? AccuracyUpdated; +public event EventHandler? KeystrokeLogged; + +private void OnStatsChanged() +{ + WpmUpdated?.Invoke(this, new WpmUpdatedEventArgs(_cachedWpm)); + AccuracyUpdated?.Invoke(this, new AccuracyUpdatedEventArgs(_cachedAccuracy)); + StatsUpdated?.Invoke(this, new StatsUpdatedEventArgs(CreateSnapshot())); +} +``` + +### 6. Error Handling and Logging + +#### Global Error Events + +Implement application-wide error handling: + +```csharp +public interface IErrorHandler +{ + event EventHandler? ErrorOccurred; + Task HandleErrorAsync(Exception exception, string context); +} + +public class ErrorOccurredEventArgs : EventArgs +{ + public Exception Exception { get; } + public string Context { get; } + public DateTime Timestamp { get; } = DateTime.UtcNow; +} +``` + +### 7. Plugin Architecture + +#### Event-Based Plugin System + +Create a plugin system using events: + +```csharp +public interface IPluginManager +{ + event EventHandler? PluginLoaded; + event EventHandler? PluginUnloaded; + + Task LoadPluginAsync(string pluginPath); + Task UnloadPluginAsync(string pluginName); +} + +public interface IPlugin +{ + string Name { get; } + Task InitializeAsync(IEventAggregator eventAggregator); + Task ShutdownAsync(); +} +``` + +### 8. Testing Improvements + +#### Event Testing Infrastructure + +Create test helpers for event verification: + +```csharp +public class EventRecorder where TEvent : EventArgs +{ + private readonly List _events = new(); + + public void Record(object? sender, TEvent e) => _events.Add(e); + + public IReadOnlyList Events => _events; + + public void Clear() => _events.Clear(); +} +``` + +### 9. Performance Optimizations + +#### Event Debouncing + +Implement debouncing for high-frequency events: + +```csharp +public class DebouncedEvent where T : EventArgs +{ + private readonly TimeSpan _delay; + private readonly Action _action; + private CancellationTokenSource? _cts; + + public void Raise(T args) + { + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + + Task.Delay(_delay, _cts.Token).ContinueWith(_ => + { + if (!_cts.IsCancellationRequested) + _action(args); + }); + } +} +``` + +### 10. Modern C# Features + +#### Records and Pattern Matching + +Use more modern C# features: + +```csharp +// Enhanced event args using records +public record KeyPressedEventArgs(char Character, KeystrokeType Type, int Position, DateTime Timestamp); + +// Pattern matching in event handlers +private void OnKeyPressed(object? sender, KeyPressedEventArgs e) +{ + var action = e.Type switch + { + KeystrokeType.Correct => HandleCorrectKey(e), + KeystrokeType.Incorrect => HandleIncorrectKey(e), + KeystrokeType.Extra => HandleExtraKey(e), + KeystrokeType.Correction => HandleCorrection(e), + _ => throw new InvalidOperationException($"Unknown keystroke type: {e.Type}") + }; + + action(); +} +``` + +## Implementation Priority + +1. **High Priority**: Expand core game events (KeyPressed, StatsUpdated, GameStarted) +2. **Medium Priority**: Implement event aggregator and async event handling +3. **Low Priority**: Plugin system and advanced features + +## Benefits of Enhanced Event-Driven Architecture + +- **Maintainability**: Loose coupling between components +- **Testability**: Easier to test individual components in isolation +- **Extensibility**: New features can be added without modifying existing code +- **Performance**: Async event handling and debouncing +- **User Experience**: More responsive UI with real-time updates + +## Migration Strategy + +1. Start by adding new events alongside existing ones +2. Gradually refactor UI code to use new events +3. Implement event aggregator +4. Add async support +5. Create plugin architecture for future extensibility + +This enhanced event-driven approach will make the codebase more maintainable, testable, and extensible while preserving the existing functionality. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0f8cff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,391 @@ +# AGENT TASK: Enhance Logging Capabilities in `Typical.Core` + +## CONTEXT + +The `Typical.Core` project, which contains the application's core business logic, lacks a dedicated logging mechanism. The `Typical` TUI project has logging, but the two are not standardized. This task involves introducing structured, high-performance logging to `Typical.Core` and standardizing the logging `EventId` convention across the entire solution to improve debuggability. + +## REQUIREMENTS + +You must perform the following actions: + +1. **Standardize `EventId` Convention:** Update all logging definitions to follow a layered numbering scheme. + * **`Typical` (TUI Layer):** Use `EventId`s in the `1000` range. + * **`Typical.Core` (Business Logic):** Use `EventId`s in the `2000` range. + +2. **Create Logging Definitions for Core Logic:** Create a new static class `CoreLogs.cs` in `Typical.Core` to house all business logic-related logging definitions using the `[LoggerMessage]` source generator. + +3. **Inject and Use Loggers in Core Classes:** Modify `GameEngine.cs` and `GameStats.cs` to accept `ILogger` via dependency injection and add calls to the new logging methods at key execution points. + +4. **Enhance Serilog Configuration:** Update the Serilog configuration in `ServiceExtensions.cs` to separate log verbosity. The console sink should be restricted to `Information` level and higher, while the file sink should capture more detailed `Debug` level logs. + +--- + +## FILE MODIFICATIONS + +### 1. File to be Modified: `src/Typical/Logging/AppLogs.cs` + +**Action:** Replace the entire contents of the file with the code below to standardize the `EventId`s. + +```csharp +using Microsoft.Extensions.Logging; +using Typical; +using Typical.TUI; + +public static partial class AppLogs +{ + // Define a log message with ID, level, template + [LoggerMessage(EventId = 1000, Level = LogLevel.Information, Message = "Application starting...")] + public static partial void ApplicationStarting(ILogger logger); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "No commands specified, starting interactive AppShell." + )] + public static partial void NoCommandsInteractive(ILogger logger); + + // Example with parameters + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Failed to process user {UserId}" + )] + public static partial void FailedToProcessUser(ILogger logger, int userId); + + [LoggerMessage( + EventId = 1003, + Level = LogLevel.Warning, + Message = "Starting direct game with Mode: {Mode}, Duration: {Duration}" + )] + public static partial void StartingGame(ILogger logger, string mode, int duration); + + [LoggerMessage( + EventId = 1004, + Level = LogLevel.Information, + Message = ("Application shutting down.") + )] + public static partial void ApplicationStopping(ILogger logger); +} +``` + +### 2. File to be Created: `src/Typical.Core/Logging/CoreLogs.cs` + +**Action:** Create a new file at the specified path with the following content. + +```csharp +using Microsoft.Extensions.Logging; +using Typical.Core.Statistics; + +namespace Typical.Core.Logging; + +public static partial class CoreLogs +{ + // --- GameEngine Logs (2000-2099) --- + [LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "New game starting.")] + public static partial void GameStarting(ILogger logger); + + [LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "Game finished successfully.")] + public static partial void GameFinished(ILogger logger); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Game quit by user.")] + public static partial void GameQuit(ILogger logger); + + [LoggerMessage(EventId = 2003, Level = LogLevel.Debug, Message = "Processing key: {KeyChar}, Type: {KeystrokeType}")] + public static partial void KeyProcessed(ILogger logger, char KeyChar, KeystrokeType KeystrokeType); + + [LoggerMessage(EventId = 2004, Level = LogLevel.Trace, Message = "Publishing game state update.")] + public static partial void PublishingState(ILogger logger); + + // --- GameStats Logs (2100-2199) --- + [LoggerMessage(EventId = 2100, Level = LogLevel.Debug, Message = "GameStats started.")] + public static partial void StatsStarted(ILogger logger); + + [LoggerMessage(EventId = 2101, Level = LogLevel.Debug, Message = "GameStats stopped. Elapsed: {ElapsedTime}ms")] + public static partial void StatsStopped(ILogger logger, double ElapsedTime); + + [LoggerMessage(EventId = 2102, Level = LogLevel.Debug, Message = "GameStats reset.")] + public static partial void StatsReset(ILogger logger); + + [LoggerMessage(EventId = 2103, Level = LogLevel.Debug, Message = "Key logged in stats: {Character} ({Type})")] + public static partial void StatsKeyLogged(ILogger logger, char Character, KeystrokeType Type); + + [LoggerMessage(EventId = 2104, Level = LogLevel.Debug, Message = "Backspace logged in stats.")] + public static partial void StatsBackspaceLogged(ILogger logger); + + [LoggerMessage(EventId = 2105, Level = LogLevel.Trace, Message = "Recalculating all statistics.")] + public static partial void RecalculatingStats(ILogger logger); +} +``` + +### 3. File to be Modified: `src/Typical.Core/GameEngine.cs` + +**Action:** Replace the entire contents of the file with the code below to inject the logger and add logging calls. + +```csharp +using System.Text; +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Logging; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class GameEngine +{ + private readonly StringBuilder _userInput; + private readonly ITextProvider _textProvider; + private readonly GameOptions _gameOptions; + private readonly IEventAggregator _eventAggregator; + private readonly GameStats _stats; + private readonly ILogger _logger; + + public GameEngine(ITextProvider textProvider, IEventAggregator eventAggregator, ILogger logger, ILoggerFactory loggerFactory) + : this(textProvider, eventAggregator, new GameOptions(), logger, loggerFactory) { } + + public GameEngine( + ITextProvider textProvider, + IEventAggregator eventAggregator, + GameOptions gameOptions, + ILogger logger, + ILoggerFactory loggerFactory + ) + { + _textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider)); + _gameOptions = gameOptions; + _userInput = new StringBuilder(); + _eventAggregator = eventAggregator; + _logger = logger; + _stats = new GameStats(_eventAggregator, null, loggerFactory.CreateLogger()); + } + + 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 bool ProcessKeyPress(ConsoleKeyInfo key) + { + if (key.Key == ConsoleKey.Escape) + { + IsOver = true; + _stats.Stop(); + CoreLogs.GameQuit(_logger); + _eventAggregator.Publish(new GameQuitEvent()); + return false; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (_userInput.Length > 0) + { + _userInput.Remove(_userInput.Length - 1, 1); + _eventAggregator.Publish(new BackspacePressedEvent()); + PublishStateUpdate(); + } + return true; + } + + if (char.IsControl(key.KeyChar)) + { + return true; + } + char inputChar = key.KeyChar; + KeystrokeType type = DetermineKeystrokeType(inputChar); + + CoreLogs.KeyProcessed(_logger, inputChar, type); + _eventAggregator.Publish(new KeyPressedEvent(inputChar, type, _userInput.Length)); + + bool isCorrect = type == KeystrokeType.Correct; + if (!_gameOptions.ForbidIncorrectEntries || isCorrect) + { + _userInput.Append(key.KeyChar); + } + + CheckEndCondition(); + PublishStateUpdate(); + + return true; + } + + private KeystrokeType DetermineKeystrokeType(char inputChar) + { + int currentPos = _userInput.Length; + if (currentPos >= TargetText.Length) + return KeystrokeType.Extra; + if (inputChar == TargetText[currentPos]) + return KeystrokeType.Correct; + return KeystrokeType.Incorrect; + } + + private void CheckEndCondition() + { + if (_userInput.ToString() == TargetText) + { + IsOver = true; + _stats.Stop(); + CoreLogs.GameFinished(_logger); + _eventAggregator.Publish(new GameEndedEvent()); + } + } + + public async Task StartNewGame() + { + CoreLogs.GameStarting(_logger); + TargetText = await _textProvider.GetTextAsync(); + _stats.Start(); + _userInput.Clear(); + IsOver = false; + PublishStateUpdate(); + } + + private void PublishStateUpdate() + { + CoreLogs.PublishingState(_logger); + var snapShot = _stats.CreateSnapshot(); + var stateEvent = new GameStateUpdatedEvent(TargetText, UserInput, snapShot, IsOver); + _eventAggregator.Publish(stateEvent); + } +} +``` + +### 4. File to be Modified: `src/Typical.Core/Statistics/GameStats.cs` + +**Action:** Replace the entire contents of the file with the code below to inject the logger and add logging calls. + +```csharp +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Logging; + +namespace Typical.Core.Statistics; + +internal class GameStats +{ + private readonly IEventAggregator _eventAggregator; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly KeystrokeHistory _keystrokeHistory = []; + private long? _startTimestamp; + private long? _endTimestamp; + private bool _statsAreDirty = true; + private double _cachedWpm; + private double _cachedAccuracy; + private CharacterStats _cachedChars = new(0, 0, 0, 0); + + public GameStats(IEventAggregator eventAggregator, TimeProvider? timeProvider, ILogger logger) + { + _eventAggregator = eventAggregator; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger; + _eventAggregator.Subscribe(OnKeyPressed); + _eventAggregator.Subscribe(OnBackspacePressed); + } + + private void OnBackspacePressed(BackspacePressedEvent @event) + { + if (!IsRunning) return; + + CoreLogs.StatsBackspaceLogged(_logger); + _keystrokeHistory.RemoveLastCharacterLog(); + _keystrokeHistory.Add(new KeystrokeLog('\b', KeystrokeType.Correction, _timeProvider.GetTimestamp())); + _statsAreDirty = true; + } + + private void OnKeyPressed(KeyPressedEvent @event) + { + if (!IsRunning) Start(); + + CoreLogs.StatsKeyLogged(_logger, @event.Character, @event.Type); + _keystrokeHistory.Add(new KeystrokeLog(@event.Character, @event.Type, _timeProvider.GetTimestamp())); + _statsAreDirty = true; + } + + 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(); + CoreLogs.StatsStarted(_logger); + } + + public void Reset() + { + _startTimestamp = null; + _endTimestamp = null; + _keystrokeHistory.Clear(); + _cachedWpm = 0; + _cachedAccuracy = 100; + _cachedChars = new CharacterStats(0, 0, 0, 0); + CoreLogs.StatsReset(_logger); + } + + public void Stop() + { + if (IsRunning) + { + _endTimestamp = _timeProvider.GetTimestamp(); + CoreLogs.StatsStopped(_logger, ElapsedTime.TotalMilliseconds); + } + } + + public GameStatisticsSnapshot CreateSnapshot() + { + if (_statsAreDirty) RecalculateAllStats(); + return new GameStatisticsSnapshot( + WordsPerMinute: _cachedWpm, + Accuracy: _cachedAccuracy, + Chars: _cachedChars, + ElapsedTime: this.ElapsedTime, + IsRunning: this.IsRunning + ); + } + + private void RecalculateAllStats() + { + CoreLogs.RecalculatingStats(_logger); + _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime); + _cachedAccuracy = _keystrokeHistory.CalculateAccuracy(); + _cachedChars = _keystrokeHistory.GetCharacterStats(); + _statsAreDirty = false; + } +} +``` + +### 5. File to be Modified: `src/Typical/Services/ServiceExtensions.cs` + +**Action:** Replace the `ConfigureSerilog` method with the updated version below. Ensure the `using Serilog.Events;` directive is present at the top of the file. + +```csharp +// Ensure this using directive exists at the top of the file +using Serilog.Events; + +// ... inside the ServiceExtensions class ... + +public static void ConfigureSerilog(this ILoggingBuilder builder) +{ + const string outputTemplate = + "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceClass}) {Message:lj}{NewLine}{Exception}"; + builder.AddSerilog( + new LoggerConfiguration() + .MinimumLevel.Debug() // Set a default minimum level + .WriteTo.File( + formatter: new MessageTemplateTextFormatter(outputTemplate), + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "app-.log"), + shared: true, + rollingInterval: RollingInterval.Day, + restrictedToMinimumLevel: LogEventLevel.Debug // Log debug events to file + ) + .Enrich.WithProperty("ApplicationName", "") + .Enrich.With() + .WriteTo.Console(outputTemplate: outputTemplate, theme: AnsiConsoleTheme.Sixteen, restrictedToMinimumLevel: LogEventLevel.Information) // Keep console clean + .CreateLogger() + ); +} +``` diff --git a/Typical.slnx b/Typical.slnx index 383f80e..603823d 100644 --- a/Typical.slnx +++ b/Typical.slnx @@ -4,6 +4,7 @@ + diff --git a/codebase_analysis.md b/codebase_analysis.md new file mode 100644 index 0000000..099a576 --- /dev/null +++ b/codebase_analysis.md @@ -0,0 +1,351 @@ +# Codebase Analysis and Improvement Recommendations for Typical + +## Overview + +The Typical project is a console-based typing tutor application built with C# and Spectre.Console. It features a modular architecture with separate projects for core logic, TUI (Text User Interface), and tests. The application uses events for game state management, which is a good foundation for event-driven design. + +## Current Event-Driven Architecture + +The codebase has basic event support in the `GameEngine` class: + +- `GameEnded` event: Fired when the game completes +- `StateChanged` event: Fired when user input changes + +These events are consumed by `TypicalGame` for UI updates. This is a solid start, but the event system can be significantly expanded to improve maintainability, testability, and extensibility. + +## Recommended Improvements + +### 1. Expand Event-Driven Architecture + +#### Additional Game Events + +Introduce more granular events to decouple game logic from UI concerns: + +```csharp +// In GameEngine.cs +public event EventHandler? KeyPressed; +public event EventHandler? BackspacePressed; +public event EventHandler? GameStarted; +public event EventHandler? StatsUpdated; +public event EventHandler? GamePaused; +public event EventHandler? GameResumed; + +// Event args classes +public class KeyPressedEventArgs : EventArgs +{ + public char Character { get; } + public KeystrokeType Type { get; } + public int Position { get; } +} + +public class StatsUpdatedEventArgs : EventArgs +{ + public GameStatisticsSnapshot Stats { get; } +} + +public class GameStartedEventArgs : EventArgs +{ + public string TargetText { get; } +} +``` + +#### Event Aggregator Pattern + +Implement an event aggregator to reduce coupling between components: + +```csharp +public interface IEventAggregator +{ + void Publish(TEvent @event) where TEvent : class; + void Subscribe(Action handler) where TEvent : class; + void Unsubscribe(Action handler) where TEvent : class; +} + +public class EventAggregator : IEventAggregator +{ + private readonly Dictionary> _handlers = new(); + + // Implementation... +} +``` + +#### Async Event Handling + +Support async event handlers for better performance: + +```csharp +public event Func? KeyPressedAsync; +``` + +### 2. UI State Management Improvements + +#### Reactive UI Updates + +Replace polling-based updates with event-driven rendering: + +```csharp +// In TypicalGame.cs +private async Task HandleStateChanged(object? sender, GameStateChangedEventArgs e) +{ + await UpdateTypingAreaAsync(); + await UpdateStatsAreaAsync(); +} + +private async Task HandleStatsUpdated(object? sender, StatsUpdatedEventArgs e) +{ + await UpdateStatsAreaAsync(); +} +``` + +#### UI Event Bus + +Create a dedicated UI event system: + +```csharp +public interface IUiEventBus +{ + Task PublishAsync(TEvent @event) where TEvent : class; + void Subscribe(Func handler) where TEvent : class; +} +``` + +### 3. Configuration and Settings Enhancements + +#### Dynamic Configuration Reloading + +Add support for runtime configuration changes: + +```csharp +public interface IConfigurationService +{ + Task ReloadConfigurationAsync(); + event EventHandler? ConfigurationChanged; +} +``` + +#### Theme Switching Events + +Make theme changes event-driven: + +```csharp +// In ThemeManager.cs +public event EventHandler? ThemeChanged; + +public async Task TrySetThemeAsync(string themeName) +{ + // ... existing logic ... + if (success) + { + ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(themeName, _activeTheme)); + } + return success; +} +``` + +### 4. Text Provider Enhancements + +#### Text Loading Events + +Add events for text provider lifecycle: + +```csharp +public interface ITextProvider +{ + Task GetTextAsync(); + event EventHandler? TextLoading; + event EventHandler? TextLoaded; + event EventHandler? TextLoadError; +} +``` + +#### Multiple Text Sources + +Support for different text sources with events: + +```csharp +public class CompositeTextProvider : ITextProvider +{ + private readonly IEnumerable _providers; + + public async Task GetTextAsync() + { + foreach (var provider in _providers) + { + try + { + var text = await provider.GetTextAsync(); + TextLoaded?.Invoke(this, new TextLoadedEventArgs(text, provider.GetType().Name)); + return text; + } + catch (Exception ex) + { + TextLoadError?.Invoke(this, new TextLoadErrorEventArgs(ex, provider.GetType().Name)); + } + } + throw new InvalidOperationException("No text providers available"); + } + + // Events... +} +``` + +### 5. Statistics and Analytics + +#### Real-time Statistics Events + +Enhance statistics with more granular events: + +```csharp +// In GameStats.cs +public event EventHandler? WpmUpdated; +public event EventHandler? AccuracyUpdated; +public event EventHandler? KeystrokeLogged; + +private void OnStatsChanged() +{ + WpmUpdated?.Invoke(this, new WpmUpdatedEventArgs(_cachedWpm)); + AccuracyUpdated?.Invoke(this, new AccuracyUpdatedEventArgs(_cachedAccuracy)); + StatsUpdated?.Invoke(this, new StatsUpdatedEventArgs(CreateSnapshot())); +} +``` + +### 6. Error Handling and Logging + +#### Global Error Events + +Implement application-wide error handling: + +```csharp +public interface IErrorHandler +{ + event EventHandler? ErrorOccurred; + Task HandleErrorAsync(Exception exception, string context); +} + +public class ErrorOccurredEventArgs : EventArgs +{ + public Exception Exception { get; } + public string Context { get; } + public DateTime Timestamp { get; } = DateTime.UtcNow; +} +``` + +### 7. Plugin Architecture + +#### Event-Based Plugin System + +Create a plugin system using events: + +```csharp +public interface IPluginManager +{ + event EventHandler? PluginLoaded; + event EventHandler? PluginUnloaded; + + Task LoadPluginAsync(string pluginPath); + Task UnloadPluginAsync(string pluginName); +} + +public interface IPlugin +{ + string Name { get; } + Task InitializeAsync(IEventAggregator eventAggregator); + Task ShutdownAsync(); +} +``` + +### 8. Testing Improvements + +#### Event Testing Infrastructure + +Create test helpers for event verification: + +```csharp +public class EventRecorder where TEvent : EventArgs +{ + private readonly List _events = new(); + + public void Record(object? sender, TEvent e) => _events.Add(e); + + public IReadOnlyList Events => _events; + + public void Clear() => _events.Clear(); +} +``` + +### 9. Performance Optimizations + +#### Event Debouncing + +Implement debouncing for high-frequency events: + +```csharp +public class DebouncedEvent where T : EventArgs +{ + private readonly TimeSpan _delay; + private readonly Action _action; + private CancellationTokenSource? _cts; + + public void Raise(T args) + { + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + + Task.Delay(_delay, _cts.Token).ContinueWith(_ => + { + if (!_cts.IsCancellationRequested) + _action(args); + }); + } +} +``` + +### 10. Modern C# Features + +#### Records and Pattern Matching + +Use more modern C# features: + +```csharp +// Enhanced event args using records +public record KeyPressedEventArgs(char Character, KeystrokeType Type, int Position, DateTime Timestamp); + +// Pattern matching in event handlers +private void OnKeyPressed(object? sender, KeyPressedEventArgs e) +{ + var action = e.Type switch + { + KeystrokeType.Correct => HandleCorrectKey(e), + KeystrokeType.Incorrect => HandleIncorrectKey(e), + KeystrokeType.Extra => HandleExtraKey(e), + KeystrokeType.Correction => HandleCorrection(e), + _ => throw new InvalidOperationException($"Unknown keystroke type: {e.Type}") + }; + + action(); +} +``` + +## Implementation Priority + +1. **High Priority**: Expand core game events (KeyPressed, StatsUpdated, GameStarted) +2. **Medium Priority**: Implement event aggregator and async event handling +3. **Low Priority**: Plugin system and advanced features + +## Benefits of Enhanced Event-Driven Architecture + +- **Maintainability**: Loose coupling between components +- **Testability**: Easier to test individual components in isolation +- **Extensibility**: New features can be added without modifying existing code +- **Performance**: Async event handling and debouncing +- **User Experience**: More responsive UI with real-time updates + +## Migration Strategy + +1. Start by adding new events alongside existing ones +2. Gradually refactor UI code to use new events +3. Implement event aggregator +4. Add async support +5. Create plugin architecture for future extensibility + +This enhanced event-driven approach will make the codebase more maintainable, testable, and extensible while preserving the existing functionality. diff --git a/src/Typical.Core/Data/Quote.cs b/src/Typical.Core/Data/Quote.cs new file mode 100644 index 0000000..6eaa93f --- /dev/null +++ b/src/Typical.Core/Data/Quote.cs @@ -0,0 +1,19 @@ +namespace Typical.Core.Data; + +public class Quote +{ + public int Id { get; set; } + public required string Text { get; set; } + public required string Author { get; set; } + public IEnumerable Tags { get; set; } = []; + public int WordCount { get; set; } + public int CharCount { get; set; } +} + +public interface IQuoteRepository +{ + Task GetRandomQuoteAsync(); + Task GetNextQuoteAsync(int currentId); + Task AddQuotesAsync(IEnumerable quotes); + Task HasAnyAsync(); +} diff --git a/src/Typical.Core/Events/BackspacePressedEvent.cs b/src/Typical.Core/Events/BackspacePressedEvent.cs new file mode 100644 index 0000000..1a4afc1 --- /dev/null +++ b/src/Typical.Core/Events/BackspacePressedEvent.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Events; + +internal record BackspacePressedEvent; diff --git a/src/Typical.Core/Events/GameEndedEvent.cs b/src/Typical.Core/Events/GameEndedEvent.cs new file mode 100644 index 0000000..f94565c --- /dev/null +++ b/src/Typical.Core/Events/GameEndedEvent.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Events; + +public record GameEndedEvent; diff --git a/src/Typical.Core/Events/GameQuitEvent.cs b/src/Typical.Core/Events/GameQuitEvent.cs new file mode 100644 index 0000000..120b7d7 --- /dev/null +++ b/src/Typical.Core/Events/GameQuitEvent.cs @@ -0,0 +1,3 @@ +namespace Typical.Core.Events; + +public record GameQuitEvent; diff --git a/src/Typical.Core/Events/GameStateUpdatedEvent.cs b/src/Typical.Core/Events/GameStateUpdatedEvent.cs new file mode 100644 index 0000000..3361ecc --- /dev/null +++ b/src/Typical.Core/Events/GameStateUpdatedEvent.cs @@ -0,0 +1,10 @@ +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +public record GameStateUpdatedEvent( + string TargetText, + string UserInput, + GameStatisticsSnapshot Statistics, + bool IsOver +); diff --git a/src/Typical.Core/Events/IEventAggregator.cs b/src/Typical.Core/Events/IEventAggregator.cs new file mode 100644 index 0000000..78097aa --- /dev/null +++ b/src/Typical.Core/Events/IEventAggregator.cs @@ -0,0 +1,76 @@ +using System.Reflection.Metadata; + +namespace Typical.Core.Events; + +public interface IEventAggregator +{ + void Subscribe(Action handler) + where TEvent : class; + void Unsubscribe(Action handler) + where TEvent : class; + void Publish(TEvent eventToPublish) + where TEvent : class; +} + +public class EventAggregator : IEventAggregator +{ + private readonly Dictionary> _handlers = []; + private readonly Lock _aggregatorLock = new(); + + public void Subscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out List? value)) + { + value = []; + _handlers[eventType] = value; + } + + value.Add(handler); + } + } + + public void Unsubscribe(Action handler) + where TEvent : class + { + var eventType = typeof(TEvent); + lock (_aggregatorLock) + { + if (_handlers.TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers.Remove(handler); + + if (eventHandlers.Count == 0) + { + _handlers.Remove(eventType); + } + } + } + } + + public void Publish(TEvent eventToPublish) + where TEvent : class + { + var eventType = typeof(TEvent); + List handlersSnapshot; + + lock (_aggregatorLock) + { + if (!_handlers.TryGetValue(eventType, out var eventHandlers)) + { + return; + } + + handlersSnapshot = eventHandlers.ToList(); + } + + foreach (var handler in handlersSnapshot) + { + ((Action)handler)(eventToPublish); + } + } +} diff --git a/src/Typical.Core/Events/KeyPressedEvent.cs b/src/Typical.Core/Events/KeyPressedEvent.cs new file mode 100644 index 0000000..60bd276 --- /dev/null +++ b/src/Typical.Core/Events/KeyPressedEvent.cs @@ -0,0 +1,5 @@ +using Typical.Core.Statistics; + +namespace Typical.Core.Events; + +internal record KeyPressedEvent(char Character, KeystrokeType Type, int Position); diff --git a/src/Typical.Core/GameEngine.cs b/src/Typical.Core/GameEngine.cs new file mode 100644 index 0000000..e92fde0 --- /dev/null +++ b/src/Typical.Core/GameEngine.cs @@ -0,0 +1,124 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Logging; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class GameEngine +{ + private readonly StringBuilder _userInput; + private readonly ITextProvider _textProvider; + private readonly GameOptions _gameOptions; + private readonly IEventAggregator _eventAggregator; + private readonly GameStats _stats; + private readonly ILogger _logger; + + public GameEngine( + ITextProvider textProvider, + IEventAggregator eventAggregator, + GameOptions gameOptions, + GameStats stats, + ILogger logger + ) + { + _userInput = new StringBuilder(); + _textProvider = textProvider ?? throw new ArgumentNullException(nameof(textProvider)); + _eventAggregator = eventAggregator; + _gameOptions = gameOptions; + _stats = stats; + _logger = logger; + } + + 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 bool ProcessKeyPress(ConsoleKeyInfo key) + { + if (key.Key == ConsoleKey.Escape) + { + IsOver = true; + _stats.Stop(); + CoreLogs.GameQuit(_logger); + _eventAggregator.Publish(new GameQuitEvent()); + return false; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (_userInput.Length > 0) + { + _userInput.Remove(_userInput.Length - 1, 1); + _eventAggregator.Publish(new BackspacePressedEvent()); + PublishStateUpdate(); + } + return true; + } + + if (char.IsControl(key.KeyChar)) + { + return true; + } + char inputChar = key.KeyChar; + KeystrokeType type = DetermineKeystrokeType(inputChar); + + CoreLogs.KeyProcessed(_logger, inputChar, type); + _eventAggregator.Publish(new KeyPressedEvent(inputChar, type, _userInput.Length)); + + bool isCorrect = type == KeystrokeType.Correct; + if (!_gameOptions.ForbidIncorrectEntries || isCorrect) + { + _userInput.Append(key.KeyChar); + } + + CheckEndCondition(); + PublishStateUpdate(); + + return true; + } + + private KeystrokeType DetermineKeystrokeType(char inputChar) + { + int currentPos = _userInput.Length; + if (currentPos >= TargetText.Length) + return KeystrokeType.Extra; + if (inputChar == TargetText[currentPos]) + return KeystrokeType.Correct; + return KeystrokeType.Incorrect; + } + + private void CheckEndCondition() + { + if (_userInput.ToString() == TargetText) + { + IsOver = true; + _stats.Stop(); + CoreLogs.GameFinished(_logger); + _eventAggregator.Publish(new GameEndedEvent()); + } + } + + public async Task StartNewGame() + { + CoreLogs.GameStarting(_logger); + var text = await _textProvider.GetTextAsync(); + TargetText = text.Text; + _stats.Start(); + _userInput.Clear(); + IsOver = false; + PublishStateUpdate(); + } + + private void PublishStateUpdate() + { + CoreLogs.PublishingState(_logger); + var snapShot = _stats.CreateSnapshot(); + var stateEvent = new GameStateUpdatedEvent(TargetText, UserInput, snapShot, IsOver); + _eventAggregator.Publish(stateEvent); + } +} diff --git a/src/Typical.Core/GameEngineFactory.cs b/src/Typical.Core/GameEngineFactory.cs new file mode 100644 index 0000000..5a7d29a --- /dev/null +++ b/src/Typical.Core/GameEngineFactory.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.Core.Text; + +namespace Typical.Core; + +public class GameEngineFactory : IGameEngineFactory +{ + private readonly ITextProvider _textProvider; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerFactory _loggerFactory; + + // The factory gets all the DI-managed services. + public GameEngineFactory( + ITextProvider textProvider, + IEventAggregator eventAggregator, + ILoggerFactory loggerFactory + ) + { + _textProvider = textProvider; + _eventAggregator = eventAggregator; + _loggerFactory = loggerFactory; + } + + // The Create method uses the runtime data to construct the GameEngine. + public GameEngine Create(GameOptions options) + { + // We need a fresh GameStats for each GameEngine instance. + var statsLogger = _loggerFactory.CreateLogger(); + var stats = new GameStats(_eventAggregator, null, statsLogger); + + var engineLogger = _loggerFactory.CreateLogger(); + + return new GameEngine(_textProvider, _eventAggregator, options, stats, engineLogger); + } +} + +public interface IGameEngineFactory +{ + GameEngine Create(GameOptions options); +} diff --git a/src/Typical.Core/GameStats.cs b/src/Typical.Core/GameStats.cs deleted file mode 100644 index 1795a91..0000000 --- a/src/Typical.Core/GameStats.cs +++ /dev/null @@ -1,93 +0,0 @@ -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/ITextProvider.cs b/src/Typical.Core/ITextProvider.cs deleted file mode 100644 index 2e92eac..0000000 --- a/src/Typical.Core/ITextProvider.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Typical.Core; - -public interface ITextProvider -{ - Task GetTextAsync(); -} diff --git a/src/Typical.Core/Logging/CoreLogs.cs b/src/Typical.Core/Logging/CoreLogs.cs new file mode 100644 index 0000000..4e432ca --- /dev/null +++ b/src/Typical.Core/Logging/CoreLogs.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; +using Typical.Core.Statistics; + +namespace Typical.Core.Logging; + +public static partial class CoreLogs +{ + // --- GameEngine Logs (2000-2099) --- + [LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "New game starting.")] + public static partial void GameStarting(ILogger logger); + + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Information, + Message = "Game finished successfully." + )] + public static partial void GameFinished(ILogger logger); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Game quit by user.")] + public static partial void GameQuit(ILogger logger); + + [LoggerMessage( + EventId = 2003, + Level = LogLevel.Debug, + Message = "Processing key: {KeyChar}, Type: {KeystrokeType}" + )] + public static partial void KeyProcessed( + ILogger logger, + char KeyChar, + KeystrokeType KeystrokeType + ); + + [LoggerMessage( + EventId = 2004, + Level = LogLevel.Trace, + Message = "Publishing game state update." + )] + public static partial void PublishingState(ILogger logger); + + // --- GameStats Logs (2100-2199) --- + [LoggerMessage(EventId = 2100, Level = LogLevel.Debug, Message = "GameStats started.")] + public static partial void StatsStarted(ILogger logger); + + [LoggerMessage( + EventId = 2101, + Level = LogLevel.Debug, + Message = "GameStats stopped. Elapsed: {ElapsedTime}ms" + )] + public static partial void StatsStopped(ILogger logger, double ElapsedTime); + + [LoggerMessage(EventId = 2102, Level = LogLevel.Debug, Message = "GameStats reset.")] + public static partial void StatsReset(ILogger logger); + + [LoggerMessage( + EventId = 2103, + Level = LogLevel.Debug, + Message = "Key logged in stats: {Character} ({Type})" + )] + public static partial void StatsKeyLogged(ILogger logger, char Character, KeystrokeType Type); + + [LoggerMessage(EventId = 2104, Level = LogLevel.Debug, Message = "Backspace logged in stats.")] + public static partial void StatsBackspaceLogged(ILogger logger); + + [LoggerMessage( + EventId = 2105, + Level = LogLevel.Trace, + Message = "Recalculating all statistics." + )] + public static partial void RecalculatingStats(ILogger logger); +} diff --git a/src/Typical.Core/CharacterStats.cs b/src/Typical.Core/Statistics/CharacterStats.cs similarity index 68% rename from src/Typical.Core/CharacterStats.cs rename to src/Typical.Core/Statistics/CharacterStats.cs index 31644b8..00aa5fa 100644 --- a/src/Typical.Core/CharacterStats.cs +++ b/src/Typical.Core/Statistics/CharacterStats.cs @@ -1,4 +1,4 @@ -namespace Typical.Core; +namespace Typical.Core.Statistics; // A simple record to hold the results of GetCharacterStats -public record CharacterStats(int Correct, int Incorrect, int Extra); +public record CharacterStats(int Correct, int Incorrect, int Extra, int Corrections); diff --git a/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs b/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs new file mode 100644 index 0000000..c5bbdb5 --- /dev/null +++ b/src/Typical.Core/Statistics/GameStatisticsSnapshot.cs @@ -0,0 +1,13 @@ +namespace Typical.Core.Statistics; + +public record GameStatisticsSnapshot( + double WordsPerMinute, + double Accuracy, + CharacterStats Chars, + TimeSpan ElapsedTime, + bool IsRunning +) +{ + public static GameStatisticsSnapshot Empty => + new(0, 100, new CharacterStats(0, 0, 0, 0), TimeSpan.Zero, false); +} diff --git a/src/Typical.Core/Statistics/GameStats.cs b/src/Typical.Core/Statistics/GameStats.cs new file mode 100644 index 0000000..f9fbf4c --- /dev/null +++ b/src/Typical.Core/Statistics/GameStats.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging; +using Typical.Core.Events; +using Typical.Core.Logging; + +namespace Typical.Core.Statistics; + +public class GameStats +{ + private readonly IEventAggregator _eventAggregator; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly KeystrokeHistory _keystrokeHistory = []; + private long? _startTimestamp; + private long? _endTimestamp; + private bool _statsAreDirty = true; + private double _cachedWpm; + private double _cachedAccuracy; + private CharacterStats _cachedChars = new(0, 0, 0, 0); + + public GameStats( + IEventAggregator eventAggregator, + TimeProvider? timeProvider, + ILogger logger + ) + { + _eventAggregator = eventAggregator; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger; + _eventAggregator.Subscribe(OnKeyPressed); + _eventAggregator.Subscribe(OnBackspacePressed); + } + + private void OnBackspacePressed(BackspacePressedEvent @event) + { + if (!IsRunning) + return; + + CoreLogs.StatsBackspaceLogged(_logger); + _keystrokeHistory.RemoveLastCharacterLog(); + _keystrokeHistory.Add( + new KeystrokeLog('\b', KeystrokeType.Correction, _timeProvider.GetTimestamp()) + ); + _statsAreDirty = true; + } + + private void OnKeyPressed(KeyPressedEvent @event) + { + if (!IsRunning) + Start(); + + CoreLogs.StatsKeyLogged(_logger, @event.Character, @event.Type); + _keystrokeHistory.Add( + new KeystrokeLog(@event.Character, @event.Type, _timeProvider.GetTimestamp()) + ); + _statsAreDirty = true; + } + + 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, _timeProvider.GetTimestamp()); + + public void Start() + { + Reset(); + _startTimestamp = _timeProvider.GetTimestamp(); + CoreLogs.StatsStarted(_logger); + } + + public void Reset() + { + _startTimestamp = null; + _endTimestamp = null; + _keystrokeHistory.Clear(); + _cachedWpm = 0; + _cachedAccuracy = 100; + _cachedChars = new CharacterStats(0, 0, 0, 0); + CoreLogs.StatsReset(_logger); + } + + public void Stop() + { + if (IsRunning) + { + _endTimestamp = _timeProvider.GetTimestamp(); + CoreLogs.StatsStopped(_logger, ElapsedTime.TotalMilliseconds); + } + } + + public GameStatisticsSnapshot CreateSnapshot() + { + if (_statsAreDirty) + RecalculateAllStats(); + return new GameStatisticsSnapshot( + WordsPerMinute: _cachedWpm, + Accuracy: _cachedAccuracy, + Chars: _cachedChars, + ElapsedTime: this.ElapsedTime, + IsRunning: this.IsRunning + ); + } + + private void RecalculateAllStats() + { + CoreLogs.RecalculatingStats(_logger); + _cachedWpm = _keystrokeHistory.CalculateWpm(ElapsedTime); + _cachedAccuracy = _keystrokeHistory.CalculateAccuracy(); + _cachedChars = _keystrokeHistory.GetCharacterStats(); + _statsAreDirty = false; + } +} diff --git a/src/Typical.Core/KeystrokeHistory.cs b/src/Typical.Core/Statistics/KeystrokeHistory.cs similarity index 66% rename from src/Typical.Core/KeystrokeHistory.cs rename to src/Typical.Core/Statistics/KeystrokeHistory.cs index e9b1779..635556d 100644 --- a/src/Typical.Core/KeystrokeHistory.cs +++ b/src/Typical.Core/Statistics/KeystrokeHistory.cs @@ -1,6 +1,6 @@ using System.Collections; -namespace Typical.Core; +namespace Typical.Core.Statistics; public class KeystrokeHistory : IEnumerable { @@ -11,11 +11,12 @@ public class KeystrokeHistory : IEnumerable 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() + private (int Correct, int Incorrect, int Extra, int Corrections) GetCounts() { int correct = 0; int incorrect = 0; int extra = 0; + int corrections = 0; foreach (var log in _logs) { @@ -27,13 +28,15 @@ public class KeystrokeHistory : IEnumerable case KeystrokeType.Incorrect: incorrect++; break; - case KeystrokeType.Extra: extra++; break; + case KeystrokeType.Correction: + corrections++; + break; } } - return (correct, incorrect, extra); + return (correct, incorrect, extra, corrections); } public void Add(KeystrokeLog log) @@ -56,7 +59,7 @@ public double CalculateAccuracy() if (Count == 0) return 100.0; - var (correct, incorrect, _) = GetCounts(); + var (correct, incorrect, _, _) = GetCounts(); int totalChars = correct + incorrect; return totalChars == 0 ? 100.0 : (double)correct / totalChars * 100.0; } @@ -67,11 +70,28 @@ public CharacterStats GetCharacterStats() return new CharacterStats( Correct: counts.Correct, Incorrect: counts.Incorrect, - Extra: counts.Extra + Extra: counts.Extra, + Corrections: counts.Corrections ); } public IEnumerator GetEnumerator() => _logs.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void RemoveLastCharacterLog() + { + // Use FindLastIndex to search from the end of the list. + int indexToRemove = _logs.FindLastIndex(log => + log.Type == KeystrokeType.Correct + || log.Type == KeystrokeType.Incorrect + || log.Type == KeystrokeType.Extra + ); + + // If a log was found (index is not -1), remove it. + if (indexToRemove != -1) + { + _logs.RemoveAt(indexToRemove); + } + } } diff --git a/src/Typical.Core/KeystrokeLog.cs b/src/Typical.Core/Statistics/KeystrokeLog.cs similarity index 71% rename from src/Typical.Core/KeystrokeLog.cs rename to src/Typical.Core/Statistics/KeystrokeLog.cs index 0582453..0c94f2c 100644 --- a/src/Typical.Core/KeystrokeLog.cs +++ b/src/Typical.Core/Statistics/KeystrokeLog.cs @@ -1,3 +1,3 @@ -namespace Typical.Core; +namespace Typical.Core.Statistics; public record struct KeystrokeLog(char Character, KeystrokeType Type, long Timestamp); diff --git a/src/Typical.Core/KeystrokeType.cs b/src/Typical.Core/Statistics/KeystrokeType.cs similarity index 57% rename from src/Typical.Core/KeystrokeType.cs rename to src/Typical.Core/Statistics/KeystrokeType.cs index 5d24c0f..d4b5672 100644 --- a/src/Typical.Core/KeystrokeType.cs +++ b/src/Typical.Core/Statistics/KeystrokeType.cs @@ -1,8 +1,9 @@ -namespace Typical.Core; +namespace Typical.Core.Statistics; public enum KeystrokeType { Correct, Incorrect, Extra, + Correction, } diff --git a/src/Typical.Core/Text/ITextProvider.cs b/src/Typical.Core/Text/ITextProvider.cs new file mode 100644 index 0000000..15b90ed --- /dev/null +++ b/src/Typical.Core/Text/ITextProvider.cs @@ -0,0 +1,6 @@ +namespace Typical.Core.Text; + +public interface ITextProvider +{ + Task GetTextAsync(); +} diff --git a/src/Typical.Core/Text/TextSample.cs b/src/Typical.Core/Text/TextSample.cs new file mode 100644 index 0000000..26fa390 --- /dev/null +++ b/src/Typical.Core/Text/TextSample.cs @@ -0,0 +1,35 @@ +namespace Typical.Core.Text; + +/// +/// Represents a piece of text to be used in a typing game, +/// including the text itself and relevant metadata. +/// This is a generic DTO, decoupled from any specific data source. +/// +public record TextSample +{ + /// + /// A unique identifier from the original data source, if available. + /// This is useful for features like "Play Next Quote". + /// + public int? SourceId { get; init; } + + /// + /// The text the user will be typing. + /// + public required string Text { get; init; } + + /// + /// The generic "source" of the text (e.g., an author's name, a book title, "Common Words")._ + /// + public required string Source { get; init; } + + /// + /// The number of words in the text. + /// + public int WordCount { get; init; } + + /// + /// The number of characters in the text. + /// + public int CharCount { get; init; } +} diff --git a/src/Typical.Core/Typical.Core.csproj b/src/Typical.Core/Typical.Core.csproj index 3686269..f8a45d7 100644 --- a/src/Typical.Core/Typical.Core.csproj +++ b/src/Typical.Core/Typical.Core.csproj @@ -1,4 +1,4 @@ - + Typical.Core net10.0 @@ -7,6 +7,12 @@ embedded preview + + + diff --git a/src/Typical.Core/TypicalGame.cs b/src/Typical.Core/TypicalGame.cs deleted file mode 100644 index 894f0fb..0000000 --- a/src/Typical.Core/TypicalGame.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; - -namespace Typical.Core; - -public class TypicalGame -{ - private readonly StringBuilder _userInput; - private readonly ITextProvider _textProvider; - private readonly GameOptions _gameOptions; - - public TypicalGame(ITextProvider textProvider) - : this(textProvider, new GameOptions()) { } - - 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 { 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; - } - - if (key.Key == ConsoleKey.Backspace && _userInput.Length > 0) - { - _userInput.Remove(_userInput.Length - 1, 1); - } - else if (!char.IsControl(key.KeyChar)) - { - int currentPos = _userInput.Length; - if (currentPos >= TargetText.Length) - { - 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) - { - IsOver = true; - Stats.Stop(); - } - - return true; - } - - public async Task StartNewGame() - { - TargetText = await _textProvider.GetTextAsync(); - Stats.Start(); - _userInput.Clear(); - IsOver = false; - } -} diff --git a/src/Typical.DataAccess/Constants.cs b/src/Typical.DataAccess/Constants.cs new file mode 100644 index 0000000..91de48b --- /dev/null +++ b/src/Typical.DataAccess/Constants.cs @@ -0,0 +1,38 @@ +namespace Typical.DataAccess; + +public static class LiteDbConstants +{ + static LiteDbConstants() + { + string? dataDir = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + + if (dataDir is null) + { + if (OperatingSystem.IsWindows()) + { + dataDir = Environment.GetEnvironmentVariable("LOCALAPPDATA")!; + } + else if (OperatingSystem.IsLinux()) + { + dataDir = Path.Combine( + Environment.GetEnvironmentVariable("HOME")!, + ".local", + "share" + ); + } + else if (OperatingSystem.IsMacOS()) + { + dataDir = Path.Combine( + Environment.GetEnvironmentVariable("HOME")!, + "Library", + "Application Support" + ); + } + } + DataDirectory = Path.Combine(dataDir!, "typical"); + } + + public static string DataDirectory { get; } + public static string DbFile => Path.Combine(DataDirectory, "typical.db"); + public static string ConnectionString => $"Filename={DbFile}"; +} diff --git a/src/Typical.DataAccess/LiteDb/DbContext.cs b/src/Typical.DataAccess/LiteDb/DbContext.cs new file mode 100644 index 0000000..75ab485 --- /dev/null +++ b/src/Typical.DataAccess/LiteDb/DbContext.cs @@ -0,0 +1,28 @@ +using LiteDB; +using Typical.Core.Data; + +namespace Typical.DataAccess.LiteDB; + +public class DbContext +{ + private readonly string connectionString; + + public DbContext(string connectionString) + { + this.connectionString = connectionString; + } + + public IEnumerable GetQuotes() + { + using var db = new LiteRepository(connectionString); + + return db.Query().ToList(); + } + + public void InsertQuotes(IEnumerable quotes) + { + using var db = new LiteRepository(connectionString); + + db.Insert(quotes); + } +} diff --git a/src/Typical.DataAccess/LiteDb/LiteDbOptions.cs b/src/Typical.DataAccess/LiteDb/LiteDbOptions.cs new file mode 100644 index 0000000..84ea0a4 --- /dev/null +++ b/src/Typical.DataAccess/LiteDb/LiteDbOptions.cs @@ -0,0 +1,13 @@ +// namespace Typical.DataAccess.LiteDB; + +// public static class LiteDbOptions +// { +// static LiteDbOptions() +// { +// var filePath = Path.Combine(BaseDirectories.DataDir, "typetype.db"); + +// ConnectionString = $"Filename={filePath}"; +// } + +// public static string ConnectionString { get; } +// } diff --git a/src/Typical.DataAccess/LiteDb/LiteDbQuoteRepository.cs b/src/Typical.DataAccess/LiteDb/LiteDbQuoteRepository.cs new file mode 100644 index 0000000..e9c23a4 --- /dev/null +++ b/src/Typical.DataAccess/LiteDb/LiteDbQuoteRepository.cs @@ -0,0 +1,82 @@ +using LiteDB; +using Typical.Core.Data; + +namespace Typical.DataAccess; + +public class LiteDbQuoteRepository : IQuoteRepository +{ + private readonly string _connectionString; + + // The repository takes the connection string as its dependency. + public LiteDbQuoteRepository(string connectionString) + { + _connectionString = connectionString; + } + + /// + /// Adds a collection of quotes to the database. + /// + public Task AddQuotesAsync(IEnumerable quotes) + { + // LiteRepository manages the connection for us. + using var db = new LiteRepository(_connectionString); + db.Insert(quotes); + + // LiteRepository methods are synchronous, so we wrap the call in a completed task. + return Task.CompletedTask; + } + + /// + /// Fetches the next quote by ID, wrapping around if at the end. + /// + public async Task GetNextQuoteAsync(int currentId) + { + using var db = new LiteRepository(_connectionString); + + // Find the first quote with an ID greater than the current one. + var nextQuote = db.Query() + .OrderBy(q => q.Id) + .Where(q => q.Id > currentId) + .Limit(1) + .FirstOrDefault(); + + if (nextQuote is null) + { + // If we didn't find one, wrap around and get the very first quote. + nextQuote = db.Query().OrderBy(q => q.Id).Limit(1).FirstOrDefault(); + } + + return await Task.FromResult(nextQuote); + } + + /// + /// Fetches a random quote from the collection. + /// + public async Task GetRandomQuoteAsync() + { + using var db = new LiteRepository(_connectionString); + + var collection = db.Database.GetCollection(); + var count = collection.Count(); + + if (count == 0) + { + return await Task.FromResult(null); + } + + var randomIndex = Random.Shared.Next(0, count); + var randomQuote = db.Query().Skip(randomIndex).Limit(1).FirstOrDefault(); + + return await Task.FromResult(randomQuote); + } + + /// + /// Checks if there is any data in the quotes collection. + /// + public async Task HasAnyAsync() + { + using var db = new LiteRepository(_connectionString); + var hasAny = db.Query().Exists(); + return await Task.FromResult(hasAny); + } +} diff --git a/src/Typical.DataAccess/LiteDb/ServiceExtensions.cs b/src/Typical.DataAccess/LiteDb/ServiceExtensions.cs new file mode 100644 index 0000000..cc58e0a --- /dev/null +++ b/src/Typical.DataAccess/LiteDb/ServiceExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Typical.DataAccess.LiteDB; + +public static class ServiceExtensions +{ + public static IServiceCollection AddTypeTypeDb( + this IServiceCollection services, + string connectionString + ) + { + services.AddSingleton(sp => new DbContext(connectionString)); + return services; + } +} diff --git a/src/Typical.DataAccess/Typical.DataAccess.csproj b/src/Typical.DataAccess/Typical.DataAccess.csproj new file mode 100644 index 0000000..d1a2503 --- /dev/null +++ b/src/Typical.DataAccess/Typical.DataAccess.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/Typical.Tests/Core/GameStatsTests.cs b/src/Typical.Tests/Core/GameStatsTests.cs index d75b840..1aa4778 100644 --- a/src/Typical.Tests/Core/GameStatsTests.cs +++ b/src/Typical.Tests/Core/GameStatsTests.cs @@ -1,7 +1,9 @@ using System; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using TUnit; -using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Statistics; namespace Typical.Tests { @@ -10,7 +12,8 @@ public class GameStatsTests [Test] public async Task InitialState_ShouldBeDefaults() { - var stats = new GameStats(); + var eventAggregator = new EventAggregator(); + var stats = new GameStats(eventAggregator, null, NullLogger.Instance); await Assert.That(stats.WordsPerMinute).IsEqualTo(0); await Assert.That(stats.Accuracy).IsEqualTo(100); @@ -21,7 +24,8 @@ public async Task InitialState_ShouldBeDefaults() public async Task Start_ShouldSetIsRunningTrue() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var eventAggregator = new EventAggregator(); + var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); stats.Start(); @@ -32,7 +36,8 @@ public async Task Start_ShouldSetIsRunningTrue() public async Task Stop_ShouldSetIsRunningFalse() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var eventAggregator = new EventAggregator(); + var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); stats.Start(); fakeTime.Advance(TimeSpan.FromSeconds(1)); @@ -45,7 +50,8 @@ public async Task Stop_ShouldSetIsRunningFalse() public async Task Update_ShouldCalculateAccuracy() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var eventAggregator = new EventAggregator(); + var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); stats.Start(); fakeTime.Advance(TimeSpan.FromSeconds(1)); @@ -54,14 +60,8 @@ public async Task Update_ShouldCalculateAccuracy() foreach (var (c, i) in target.Zip(input)) { - if (c == i) - { - stats.LogKeystroke(c, KeystrokeType.Correct); - } - else - { - stats.LogKeystroke(i, KeystrokeType.Incorrect); - } + var type = c == i ? KeystrokeType.Correct : KeystrokeType.Incorrect; + eventAggregator.Publish(new KeyPressedEvent(i, type, 0)); } await Assert.That(stats.Accuracy).IsEqualTo(80); } @@ -70,7 +70,8 @@ public async Task Update_ShouldCalculateAccuracy() public async Task Update_ShouldCalculateWordsPerMinute() { var fakeTime = new FakeTimeProvider(); - var stats = new GameStats(fakeTime); + var eventAggregator = new EventAggregator(); + var stats = new GameStats(eventAggregator, fakeTime, NullLogger.Instance); stats.Start(); fakeTime.Advance(TimeSpan.FromSeconds(1)); @@ -79,14 +80,8 @@ public async Task Update_ShouldCalculateWordsPerMinute() foreach (var (c, i) in target.Zip(input)) { - if (c == i) - { - stats.LogKeystroke(c, KeystrokeType.Correct); - } - else - { - stats.LogKeystroke(i, KeystrokeType.Incorrect); - } + var type = c == i ? KeystrokeType.Correct : KeystrokeType.Incorrect; + eventAggregator.Publish(new KeyPressedEvent(i, type, 0)); } await Assert.That(stats.WordsPerMinute).IsEqualTo(60); diff --git a/src/Typical.Tests/TypicalGameTests.cs b/src/Typical.Tests/GameEngineTests.cs similarity index 71% rename from src/Typical.Tests/TypicalGameTests.cs rename to src/Typical.Tests/GameEngineTests.cs index 830f6b9..52769f6 100644 --- a/src/Typical.Tests/TypicalGameTests.cs +++ b/src/Typical.Tests/GameEngineTests.cs @@ -1,4 +1,8 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Typical.Core; +using Typical.Core.Events; +using Typical.Core.Statistics; namespace Typical.Tests; @@ -7,6 +11,9 @@ public class TypicalGameTests private readonly MockTextProvider _mockTextProvider; private readonly GameOptions _defaultOptions; private readonly GameOptions _strictOptions; + private readonly ILogger _logger; + private readonly IEventAggregator _eventAggregator; + private readonly GameStats _stats; public TypicalGameTests() { @@ -14,6 +21,9 @@ public TypicalGameTests() _mockTextProvider = new MockTextProvider(); _defaultOptions = new GameOptions(); _strictOptions = new GameOptions { ForbidIncorrectEntries = true }; + _logger = NullLogger.Instance; + _eventAggregator = new EventAggregator(); + _stats = new GameStats(_eventAggregator, null, NullLogger.Instance); } // --- StartNewGame Tests --- @@ -24,7 +34,13 @@ public async Task StartNewGame_Always_LoadsTextFromProvider() // Arrange var expectedText = "This is a test."; _mockTextProvider.SetText(expectedText); - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); // Act await game.StartNewGame(); @@ -38,7 +54,13 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() { // Arrange _mockTextProvider.SetText("some text"); - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); await game.StartNewGame(); // Simulate playing the game @@ -65,7 +87,13 @@ public async Task StartNewGame_WhenGameWasAlreadyInProgress_ResetsState() public async Task ProcessKeyPress_EscapeKey_EndsGameAndReturnsFalse() { // Arrange - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); // Act var result = game.ProcessKeyPress( @@ -81,7 +109,13 @@ public async Task ProcessKeyPress_EscapeKey_EndsGameAndReturnsFalse() public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() { // Arrange - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); game.ProcessKeyPress(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false)); game.ProcessKeyPress(new ConsoleKeyInfo('b', ConsoleKey.B, false, false, false)); await Assert.That(game.UserInput).IsEqualTo("ab"); @@ -105,7 +139,13 @@ public async Task ProcessKeyPress_BackspaceKey_RemovesLastCharacter() public async Task ProcessKeyPress_BackspaceOnEmptyInput_DoesNothing() { // Arrange - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); await Assert.That(game.UserInput).IsEmpty(); // Act @@ -128,7 +168,13 @@ public async Task ProcessKeyPress_WhenGameIsCompleted_SetsIsOverToTrue() { // Arrange _mockTextProvider.SetText("hi"); - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); await game.StartNewGame(); // Act @@ -147,7 +193,13 @@ public async Task ProcessKeyPress_InStrictModeAndCorrectKey_AppendsCharacter() { // Arrange _mockTextProvider.SetText("abc"); - var game = new TypicalGame(_mockTextProvider, _strictOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _strictOptions, + _stats, + _logger + ); await game.StartNewGame(); // Act @@ -162,7 +214,13 @@ public async Task ProcessKeyPress_InStrictModeAndIncorrectKey_DoesNotAppendChara { // Arrange _mockTextProvider.SetText("abc"); - var game = new TypicalGame(_mockTextProvider, _strictOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _strictOptions, + _stats, + _logger + ); await game.StartNewGame(); await Assert.That(game.UserInput).IsEmpty(); @@ -178,7 +236,13 @@ public async Task ProcessKeyPress_InDefaultModeAndIncorrectKey_AppendsCharacter( { // Arrange _mockTextProvider.SetText("abc"); - var game = new TypicalGame(_mockTextProvider, _defaultOptions); + var game = new GameEngine( + _mockTextProvider, + _eventAggregator, + _defaultOptions, + _stats, + _logger + ); await game.StartNewGame(); await Assert.That(game.UserInput).IsEmpty(); diff --git a/src/Typical.Tests/MarkupGeneratorTests.cs b/src/Typical.Tests/MarkupGeneratorTests.cs index 5f587f8..9f0aeac 100644 --- a/src/Typical.Tests/MarkupGeneratorTests.cs +++ b/src/Typical.Tests/MarkupGeneratorTests.cs @@ -37,7 +37,9 @@ public async Task BuildMarkupOptimized_PartiallyTypedAndCorrect_ReturnsCorrectAn var result = _generator.BuildMarkupString(target, typed); // Assert - await Assert.That(result).IsEqualTo("[default on green]Hello[/][grey] world[/]"); + await Assert + .That(result) + .IsEqualTo("[default on green]Hello[/][grey][underline] [/]world[/]"); } [Test] @@ -68,7 +70,7 @@ public async Task BuildMarkupOptimized_NothingTyped_ReturnsFullyUntypedMarkup() var result = _generator.BuildMarkupString(target, typed); // Assert - await Assert.That(result).IsEqualTo("[grey]Hello world[/]"); + await Assert.That(result).IsEqualTo("[grey][underline]H[/]ello world[/]"); } // --- Edge Cases --- diff --git a/src/Typical.Tests/MockTextProvider.cs b/src/Typical.Tests/MockTextProvider.cs index df575e2..9da7ce1 100644 --- a/src/Typical.Tests/MockTextProvider.cs +++ b/src/Typical.Tests/MockTextProvider.cs @@ -1,4 +1,4 @@ -using Typical.Core; +using Typical.Core.Text; namespace Typical.Tests; @@ -11,10 +11,10 @@ public void SetText(string text) _textToReturn = text; } - public Task GetTextAsync() + public Task GetTextAsync() { // Task.FromResult is the perfect way to simulate an // async operation that completes immediately. - return Task.FromResult(_textToReturn); + return Task.FromResult(new TextSample() { Source = "Tests", Text = _textToReturn }); } } diff --git a/src/Typical/ApplicationCommands.cs b/src/Typical/ApplicationCommands.cs new file mode 100644 index 0000000..84cdb1f --- /dev/null +++ b/src/Typical/ApplicationCommands.cs @@ -0,0 +1,65 @@ +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI; +using Typical.TUI.Views; + +namespace Typical; + +// The [Command] attribute on the class is optional but good practice. +public class ApplicationCommands +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + // The DI container will inject the services we need here. + public ApplicationCommands( + IServiceProvider serviceProvider, + ILogger logger + ) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// The entry point for interactive mode (when no arguments are given). + /// + [Command("")] + public async Task RunInteractive() + { + AppLogs.NoCommandsInteractive(_logger); + + // Resolve the AppShell from the DI container and run it. + var appShell = _serviceProvider.GetRequiredService(); + await appShell.RunAsync(); + } + + /// + /// Directly starts a typing game, bypassing the main menu. + /// + [Command("play")] + public async Task Play(string mode = "Quote", int duration = 60) + { + AppLogs.StartingGame(_logger, mode, duration); + + // Resolve a GameView directly from the DI container. + // This is a "one-shot" game session. + var gameView = _serviceProvider.GetRequiredService(); + + // We would need to pass these options to the GameView to configure the game. + // For example: await gameView.RunAsync(new GameOptions { Mode = mode, Duration = duration }); + await gameView.RenderAsync(); // Simplified for this example + } + + /// + /// Displays user statistics directly. + /// + [Command("stats")] + public async Task ShowStats() + { + _logger.LogInformation("Displaying stats view."); + var statsView = _serviceProvider.GetRequiredService(); + await statsView.RenderAsync(); + } +} diff --git a/src/Typical/Logging/AppLogs.cs b/src/Typical/Logging/AppLogs.cs new file mode 100644 index 0000000..45d345d --- /dev/null +++ b/src/Typical/Logging/AppLogs.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging; +using Typical; +using Typical.TUI; + +public static partial class AppLogs +{ + // Define a log message with ID, level, template + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "Application starting..." + )] + public static partial void ApplicationStarting(ILogger logger); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "No commands specified, starting interactive AppShell." + )] + public static partial void NoCommandsInteractive(ILogger logger); + + // Example with parameters + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Failed to process user {UserId}" + )] + public static partial void FailedToProcessUser(ILogger logger, int userId); + + [LoggerMessage( + EventId = 1003, + Level = LogLevel.Warning, + Message = "Starting direct game with Mode: {Mode}, Duration: {Duration}" + )] + public static partial void StartingGame(ILogger logger, string mode, int duration); + + [LoggerMessage( + EventId = 1004, + Level = LogLevel.Information, + Message = ("Application shutting down.") + )] + public static partial void ApplicationStopping(ILogger logger); +} diff --git a/src/Typical/Logging/SourceClassEnricher.cs b/src/Typical/Logging/SourceClassEnricher.cs new file mode 100644 index 0000000..b7abe1e --- /dev/null +++ b/src/Typical/Logging/SourceClassEnricher.cs @@ -0,0 +1,19 @@ +using Serilog.Core; +using Serilog.Events; + +public class SourceClassEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if ( + logEvent.Properties.TryGetValue("SourceContext", out var value) + && value is ScalarValue sv + && sv.Value is string fullName + ) + { + var shortName = fullName.Split('.').Last(); + var property = propertyFactory.CreateProperty("SourceClass", shortName); + logEvent.AddOrUpdateProperty(property); + } + } +} diff --git a/src/Typical/MarkupGenerator.cs b/src/Typical/MarkupGenerator.cs index c617a8f..ccad925 100644 --- a/src/Typical/MarkupGenerator.cs +++ b/src/Typical/MarkupGenerator.cs @@ -21,7 +21,7 @@ internal string BuildMarkupString(string target, string typed) var typedLength = typed.Length; TypingResult currentState = TypingResult.Untyped; - if (typedLength > 0) + if (typedLength > 0 && target.Length > 0) { currentState = target[0] == typed[0] ? TypingResult.Correct : TypingResult.Incorrect; } @@ -45,8 +45,16 @@ internal string BuildMarkupString(string target, string typed) builder.Append(GetMarkupForState(charState)); currentState = charState; } + var escapedChar = Markup.Escape(target[i].ToString()); - builder.Append(Markup.Escape(target[i].ToString())); + if (i == typedLength) + { + builder.Append($"[underline]{escapedChar}[/]"); + } + else + { + builder.Append(escapedChar); + } } builder.Append("[/]"); diff --git a/src/Typical/Program.cs b/src/Typical/Program.cs index 0a69bce..d989362 100644 --- a/src/Typical/Program.cs +++ b/src/Typical/Program.cs @@ -1,11 +1,10 @@ -using System.Reflection; +using ConsoleAppFramework; using DotNetPathUtils; -using Microsoft.Extensions.Configuration; -using Spectre.Console; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualBasic; using Typical; -using Typical.Core; -using Typical.TUI.Runtime; -using Typical.TUI.Settings; +using Typical.DataAccess; +using Typical.Services; using Velopack; var pathHelper = new PathEnvironmentHelper( @@ -17,37 +16,29 @@ ); if (OperatingSystem.IsWindows()) { - var appDirectory = Path.GetDirectoryName(AppContext.BaseDirectory); + var appDirectory = Path.GetDirectoryName(AppContext.BaseDirectory)!; VelopackApp .Build() - .OnAfterInstallFastCallback(v => pathHelper.EnsureDirectoryIsInPath(appDirectory!)) + .OnAfterInstallFastCallback(v => + { + pathHelper.EnsureDirectoryIsInPath(appDirectory); + var dbFile = Path.Combine(appDirectory, "typical.db"); + if (!Directory.Exists(LiteDbConstants.DataDirectory)) + Directory.CreateDirectory(LiteDbConstants.DataDirectory); + File.Move(dbFile, LiteDbConstants.DbFile, true); + }) .OnBeforeUninstallFastCallback(v => pathHelper.RemoveDirectoryFromPath(appDirectory!)) .Run(); } -var configuration = new ConfigurationBuilder().AddJsonFile("config.json").Build(); -var appSettings = configuration.Get()!; +var services = new ServiceCollection(); -var themeManager = new ThemeManager(appSettings.Themes.ToRuntimeThemes(), defaultTheme: "Default"); -var layoutFactory = new LayoutFactory(appSettings.Layouts.ToRuntimeLayouts()); +services.RegisterAppServices(); -string quotePath = Path.Combine(AppContext.BaseDirectory, "quote.txt"); +ConsoleApp.ServiceProvider = services.BuildServiceProvider(); -string text = File.Exists(quotePath) - ? await File.ReadAllTextAsync(quotePath) - : "The quick brown fox jumps over the lazy dog."; +var app = ConsoleApp.Create(); -ITextProvider textProvider = new StaticTextProvider(text); +app.Add(); -var game = new TypicalGame(textProvider); -await game.StartNewGame(); -var markupGenerator = new MarkupGenerator(); -var runner = new GameRunner( - game, - themeManager, - markupGenerator, - layoutFactory, - AnsiConsole.Console -); -runner.Run(); -Console.Clear(); +await app.RunAsync(args); diff --git a/src/Typical/Services/ServiceExtensions.cs b/src/Typical/Services/ServiceExtensions.cs new file mode 100644 index 0000000..0f67e52 --- /dev/null +++ b/src/Typical/Services/ServiceExtensions.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Display; +using Serilog.Sinks.SystemConsole.Themes; +using Spectre.Console; +using Typical.Core; +using Typical.Core.Data; +using Typical.Core.Events; +using Typical.Core.Statistics; +using Typical.Core.Text; +using Typical.DataAccess; +using Typical.DataAccess.LiteDB; +using Typical.TUI; +using Typical.TUI.Runtime; +using Typical.TUI.Settings; +using Typical.TUI.Views; + +namespace Typical.Services; + +public static class ServiceExtensions +{ + public static IConfiguration CreateConfiguration() => + new ConfigurationBuilder().AddJsonFile("./config.json", false).Build(); + + public static void ConfigureSerilog(this ILoggingBuilder builder) + { + const string outputTemplate = + "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceClass}) {Message:lj}{NewLine}{Exception}"; + builder.AddSerilog( + new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File( + formatter: new MessageTemplateTextFormatter(outputTemplate), + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "app-.log"), + shared: true, + rollingInterval: RollingInterval.Day, + restrictedToMinimumLevel: LogEventLevel.Debug + ) + .Enrich.WithProperty("ApplicationName", "") + .Enrich.With() +#if DEBUG + .WriteTo.Console( + outputTemplate: outputTemplate, + theme: AnsiConsoleTheme.Sixteen, + restrictedToMinimumLevel: LogEventLevel.Information + ) +#endif + .CreateLogger() + ); + } + + public static IServiceCollection RegisterAppServices(this IServiceCollection services) + { + var configuration = CreateConfiguration(); + var appSettings = configuration.Get()!; + services.AddLogging(ConfigureSerilog); + services.AddSingleton(); + services.AddSingleton(configuration); + services.AddSingleton(); + services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(_ => new ThemeManager( + appSettings.Themes.ToRuntimeThemes(), + defaultTheme: "Default" + )); + services.AddSingleton(_ => new LayoutFactory(appSettings.Layouts.ToRuntimeLayouts())); + + services.AddScoped(_ => new LiteDbQuoteRepository( + LiteDbConstants.ConnectionString + )); + // ... other repositories + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} diff --git a/src/Typical/StaticTextProvider.cs b/src/Typical/StaticTextProvider.cs deleted file mode 100644 index 490adad..0000000 --- a/src/Typical/StaticTextProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Typical.Core; - -namespace Typical; - -internal class StaticTextProvider(string text) : ITextProvider -{ - private readonly string _text = text; - - public Task GetTextAsync() => Task.FromResult(_text); -} diff --git a/src/Typical/TUI/AppShell.cs b/src/Typical/TUI/AppShell.cs new file mode 100644 index 0000000..2b5dcde --- /dev/null +++ b/src/Typical/TUI/AppShell.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Typical.TUI.Views; + +namespace Typical.TUI; + +public class AppShell +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AppShell(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task RunAsync() + { + AppLogs.ApplicationStarting(_logger); + IView currentView = _serviceProvider.GetRequiredService(); + + if (currentView != null) + { + await currentView.RenderAsync(); + + // The view's RenderAsync method would return the next view to transition to, + // or null to quit. + // e.g., MainMenuView returns a new GameView when the user selects "Start". + // currentView = await currentView.GetNextViewAsync(); + } + AppLogs.ApplicationStopping(_logger); + } +} diff --git a/src/Typical/GameRunner.cs b/src/Typical/TUI/Views/GameView.cs similarity index 55% rename from src/Typical/GameRunner.cs rename to src/Typical/TUI/Views/GameView.cs index 3dbfc61..8d8efc5 100644 --- a/src/Typical/GameRunner.cs +++ b/src/Typical/TUI/Views/GameView.cs @@ -1,45 +1,71 @@ -using System.Diagnostics; using Spectre.Console; using Spectre.Console.Rendering; using Typical.Core; -using Typical.TUI; +using Typical.Core.Events; +using Typical.Core.Statistics; using Typical.TUI.Runtime; using Typical.TUI.Settings; -namespace Typical; +namespace Typical.TUI.Views; -public class GameRunner +public class GameView : IView { private readonly MarkupGenerator _markupGenerator; - private readonly TypicalGame _engine; + + private GameEngine _engine = default!; + private readonly IGameEngineFactory _gameEngineFactory; private readonly ThemeManager _theme; private readonly LayoutFactory _layoutFactory; private readonly IAnsiConsole _console; - - public GameRunner( - TypicalGame engine, + private string _targetText = string.Empty; + private string _userInput = string.Empty; + private GameStatisticsSnapshot _statistics = GameStatisticsSnapshot.Empty; + private bool _isGameOver; + private bool _needsRefresh; + + public GameView( + IGameEngineFactory gameEngineFactory, ThemeManager theme, MarkupGenerator markupGenerator, LayoutFactory layoutFactory, + IEventAggregator eventAggregator, IAnsiConsole console ) { - _engine = engine; + _gameEngineFactory = gameEngineFactory; _theme = theme; _markupGenerator = markupGenerator; _layoutFactory = layoutFactory; _console = console; + + eventAggregator.Subscribe(OnGameStateUpdated); } - public void Run() + private void OnGameStateUpdated(GameStateUpdatedEvent e) { + // Cache the new state + _targetText = e.TargetText; + _userInput = e.UserInput; + _statistics = e.Statistics; + _isGameOver = e.IsOver; + + _needsRefresh = true; + } + + public async Task RenderAsync() + { + await RenderAsync(GameOptions.Default); + } + + public async Task RenderAsync(GameOptions options) + { + _engine = _gameEngineFactory.Create(options); var layout = _layoutFactory.Build(LayoutName.Dashboard); - const int statsUpdateIntervalMs = 1000; // Update stats every 2 seconds - var statsTimer = Stopwatch.StartNew(); - _console + await _console .Live(layout) - .Start(ctx => + .StartAsync(async ctx => { + await _engine.StartNewGame(); var typingArea = layout[LayoutSection.TypingArea.Value]; var statsArea = layout[LayoutSection.GameInfo.Value]; var headerArea = layout[LayoutSection.Header.Value]; @@ -52,17 +78,13 @@ public void Run() int lastHeight = Console.WindowHeight; int lastWidth = Console.WindowWidth; - while (true) + while (!_isGameOver) { - bool needsTypingRefresh = false; - bool needsStatsRefresh = false; - if (Console.WindowWidth != lastWidth || Console.WindowHeight != lastHeight) { lastWidth = Console.WindowWidth; lastHeight = Console.WindowHeight; - needsTypingRefresh = true; - needsStatsRefresh = true; + _needsRefresh = true; } if (Console.KeyAvailable) @@ -70,34 +92,18 @@ public void Run() var key = Console.ReadKey(true); if (!_engine.ProcessKeyPress(key)) break; - - needsTypingRefresh = true; - } - - 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) + if (_needsRefresh) { + layout[LayoutSection.TypingArea.Value].Update(CreateTypingArea()); + layout[LayoutSection.GameInfo.Value].Update(CreateGameInfoArea()); ctx.Refresh(); + _needsRefresh = false; } - if (_engine.IsOver) + if (_isGameOver) { - typingArea.Update(CreateTypingArea()); - statsArea.Update(CreateGameInfoArea()); ctx.Refresh(); Thread.Sleep(500); break; @@ -112,20 +118,23 @@ public void Run() private IRenderable CreateGameInfoArea() { + if (_statistics is null) + return new Text(""); + 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}"); + grid.AddRow("WPM:", $"{_statistics.WordsPerMinute:F1}"); + grid.AddRow("Accuracy:", $"{_statistics.Accuracy:F1}%"); + grid.AddRow("Correct Chars:", $"{_statistics.Chars.Correct}"); + grid.AddRow("Incorrect Chars:", $"{_statistics.Chars.Incorrect}"); + grid.AddRow("Extra Chars:", $"{_statistics.Chars.Extra}"); + grid.AddRow("Elapsed:", $"{_statistics.ElapsedTime:mm\\:ss}"); return _theme.Apply(grid, LayoutSection.GameInfo); } private IRenderable CreateTypingArea() { - var markup = _markupGenerator.BuildMarkupOptimized(_engine.TargetText, _engine.UserInput); + var markup = _markupGenerator.BuildMarkupOptimized(_targetText, _userInput); return _theme.Apply(markup, LayoutSection.TypingArea); } diff --git a/src/Typical/TUI/Views/IView.cs b/src/Typical/TUI/Views/IView.cs new file mode 100644 index 0000000..b66cfb0 --- /dev/null +++ b/src/Typical/TUI/Views/IView.cs @@ -0,0 +1,23 @@ +namespace Typical.TUI.Views; + +public interface IView +{ + // Renders the content of the view. + Task RenderAsync(); +} + +public class StatsView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} + +public class MainMenuView : IView +{ + public Task RenderAsync() + { + throw new NotImplementedException(); + } +} diff --git a/src/Typical/Text/QuoteRepositoryTextProvider.cs b/src/Typical/Text/QuoteRepositoryTextProvider.cs new file mode 100644 index 0000000..3b58eeb --- /dev/null +++ b/src/Typical/Text/QuoteRepositoryTextProvider.cs @@ -0,0 +1,57 @@ +using Typical.Core.Data; +using Typical.Core.Text; + +namespace Typical; + +public class QuoteRepositoryTextProvider : ITextProvider +{ + private readonly IQuoteRepository _quoteRepository; + private static readonly TextSample FallbackSample = new() + { + Text = "The quick brown fox jumps over the lazy dog.", + Source = "Pangram", + WordCount = 9, + CharCount = 43, + }; + + // It depends on the INTERFACE, not the concrete LiteDB implementation. + public QuoteRepositoryTextProvider(IQuoteRepository quoteRepository) + { + _quoteRepository = quoteRepository; + } + + public async Task GetNextTextSampleAsync(int? currentSampleId) + { + if (currentSampleId is null) + { + return await GetTextAsync(); + } + + var quote = await _quoteRepository.GetNextQuoteAsync(currentSampleId.Value); + + return quote is null ? FallbackSample : AdaptQuoteToTextSample(quote); + } + + public async Task GetTextAsync() + { + var quote = await _quoteRepository.GetRandomQuoteAsync(); + + return quote is null ? FallbackSample : AdaptQuoteToTextSample(quote); + } + + /// + /// Private helper to perform the mapping from the data model to the application DTO. + /// This is the core responsibility of the adapter pattern. + /// + private TextSample AdaptQuoteToTextSample(Quote quote) + { + return new TextSample + { + SourceId = quote.Id, + Text = quote.Text, + Source = quote.Author, + WordCount = quote.WordCount, + CharCount = quote.CharCount, + }; + } +} diff --git a/src/Typical/Text/StaticTextProvider.cs b/src/Typical/Text/StaticTextProvider.cs new file mode 100644 index 0000000..cf45373 --- /dev/null +++ b/src/Typical/Text/StaticTextProvider.cs @@ -0,0 +1,14 @@ +using Typical.Core.Text; + +namespace Typical; + +public class StaticTextProvider(string text) : ITextProvider +{ + private readonly string _text = text; + + public async Task GetTextAsync() + { + var val = new TextSample() { Text = _text, Source = "Static Text Provider" }; + return await Task.FromResult(val); + } +} diff --git a/src/Typical/Typical.csproj b/src/Typical/Typical.csproj index 7756b95..9da22c8 100644 --- a/src/Typical/Typical.csproj +++ b/src/Typical/Typical.csproj @@ -7,12 +7,16 @@ enable enable true - true + embedded true true + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - @@ -44,12 +47,13 @@ PreserveNewest - + PreserveNewest + diff --git a/src/Typical/quote.txt b/src/Typical/quote.txt deleted file mode 100644 index 83db302..0000000 --- a/src/Typical/quote.txt +++ /dev/null @@ -1 +0,0 @@ -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 diff --git a/src/Typical/typical.db b/src/Typical/typical.db new file mode 100644 index 0000000..0b0eb35 Binary files /dev/null and b/src/Typical/typical.db differ