global using Attribute = Terminal.Gui.Drawing.Attribute; global using Color = Terminal.Gui.Drawing.Color; global using CM = Terminal.Gui.Configuration.ConfigurationManager; global using Terminal.Gui.App; global using Terminal.Gui.ViewBase; global using Terminal.Gui.Drivers; global using Terminal.Gui.Input; global using Terminal.Gui.Configuration; global using Terminal.Gui.Views; global using Terminal.Gui.Drawing; global using Terminal.Gui.Text; global using Terminal.Gui.FileServices; using System.CommandLine; using System.CommandLine.Builder; using System.CommandLine.Parsing; using System.Data; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Reflection; using System.Reflection.Metadata; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Serilog; using Serilog.Core; using Serilog.Events; using Command = Terminal.Gui.Input.Command; using ILogger = Microsoft.Extensions.Logging.ILogger; #nullable enable namespace UICatalog; /// /// UI Catalog is a comprehensive sample library and test app for Terminal.Gui. It provides a simple UI for adding to /// the /// catalog of scenarios. /// /// /// UI Catalog attempts to satisfy the following goals: /// /// /// /// Be an easy-to-use showcase for Terminal.Gui concepts and features. /// /// /// Provide sample code that illustrates how to properly implement said concepts & features. /// /// /// Make it easy for contributors to add additional samples in a structured way. /// /// /// /// public class UICatalog { private static string? _forceDriver; private static string? _uiCatalogDriver; private static string? _scenarioDriver; public static string LogFilePath { get; set; } = string.Empty; public static LoggingLevelSwitch LogLevelSwitch { get; } = new (); public const string LOGFILE_LOCATION = "logs"; public static UICatalogCommandLineOptions Options { get; set; } private static int Main (string [] args) { Console.OutputEncoding = Encoding.Default; if (Debugger.IsAttached) { CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo ("en-US"); } UICatalogRunnable.CachedScenarios = Scenario.GetScenarios (); UICatalogRunnable.CachedCategories = Scenario.GetAllCategories (); // Process command line args // If no driver is provided, the default driver is used. // Get allowed driver names string? [] allowedDrivers = Application.GetDriverTypes ().Item2.ToArray (); Option driverOption = new Option ("--driver", "The IDriver to use.") .FromAmong (allowedDrivers!); driverOption.SetDefaultValue (string.Empty); driverOption.AddAlias ("-d"); driverOption.AddAlias ("--d"); // Add validator separately (not chained) driverOption.AddValidator (result => { var value = result.GetValueOrDefault (); if (result.Tokens.Count > 0 && !allowedDrivers.Contains (value)) { result.ErrorMessage = $"Invalid driver name '{value}'. Allowed values: {string.Join (", ", allowedDrivers)}"; } }); // Configuration Management Option disableConfigManagement = new ( "--disable-cm", "Indicates Configuration Management should not be enabled. Only `ConfigLocations.HardCoded` settings will be loaded."); disableConfigManagement.AddAlias ("-dcm"); disableConfigManagement.AddAlias ("--dcm"); Option benchmarkFlag = new ("--benchmark", "Enables benchmarking. If a Scenario is specified, just that Scenario will be benchmarked."); benchmarkFlag.AddAlias ("-b"); benchmarkFlag.AddAlias ("--b"); Option benchmarkTimeout = new ( "--timeout", () => Scenario.BenchmarkTimeout, $"The maximum time in milliseconds to run a benchmark for. Default is {Scenario.BenchmarkTimeout}ms."); benchmarkTimeout.AddAlias ("-t"); benchmarkTimeout.AddAlias ("--t"); Option resultsFile = new ("--file", "The file to save benchmark results to. If not specified, the results will be displayed in a TableView."); resultsFile.AddAlias ("-f"); resultsFile.AddAlias ("--f"); // what's the app name? LogFilePath = $"{LOGFILE_LOCATION}/{Assembly.GetExecutingAssembly ().GetName ().Name}"; Option debugLogLevel = new Option ("--debug-log-level", $"The level to use for logging (debug console and {LogFilePath})").FromAmong ( Enum.GetNames () ); debugLogLevel.SetDefaultValue ("Warning"); debugLogLevel.AddAlias ("-dl"); debugLogLevel.AddAlias ("--dl"); Argument scenarioArgument = new Argument ( "scenario", description: "The name of the Scenario to run. If not provided, the UI Catalog UI will be shown.", getDefaultValue: () => "none" ).FromAmong ( UICatalogRunnable.CachedScenarios.Select (s => s.GetName ()) .Append ("none") .ToArray () ); var rootCommand = new RootCommand ("A comprehensive sample library and test app for Terminal.Gui") { scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement }; rootCommand.SetHandler ( context => { var options = new UICatalogCommandLineOptions { Scenario = context.ParseResult.GetValueForArgument (scenarioArgument), Driver = context.ParseResult.GetValueForOption (driverOption) ?? string.Empty, DontEnableConfigurationManagement = context.ParseResult.GetValueForOption (disableConfigManagement), Benchmark = context.ParseResult.GetValueForOption (benchmarkFlag), BenchmarkTimeout = context.ParseResult.GetValueForOption (benchmarkTimeout), ResultsFile = context.ParseResult.GetValueForOption (resultsFile) ?? string.Empty, DebugLogLevel = context.ParseResult.GetValueForOption (debugLogLevel) ?? "Warning" /* etc. */ }; // See https://github.com/dotnet/command-line-api/issues/796 for the rationale behind this hackery Options = options; } ); var helpShown = false; Parser parser = new CommandLineBuilder (rootCommand) .UseHelp (ctx => helpShown = true) .Build (); parser.Invoke (args); if (helpShown) { return 0; } var parseResult = parser.Parse (args); if (parseResult.Errors.Count > 0) { foreach (var error in parseResult.Errors) { Console.Error.WriteLine (error.Message); } return 1; // Non-zero exit code for error } Scenario.BenchmarkTimeout = Options.BenchmarkTimeout; Logging.Logger = CreateLogger (); UICatalogMain (Options); Application.ForceDriver = string.Empty; return 0; } public static LogEventLevel LogLevelToLogEventLevel (LogLevel logLevel) { return logLevel switch { LogLevel.Trace => LogEventLevel.Verbose, LogLevel.Debug => LogEventLevel.Debug, LogLevel.Information => LogEventLevel.Information, LogLevel.Warning => LogEventLevel.Warning, LogLevel.Error => LogEventLevel.Error, LogLevel.Critical => LogEventLevel.Fatal, LogLevel.None => LogEventLevel.Fatal, // Default to Fatal if None is specified _ => LogEventLevel.Fatal // Default to Information for any unspecified LogLevel }; } private static ILogger CreateLogger () { // Configure Serilog to write logs to a file LogLevelSwitch.MinimumLevel = LogLevelToLogEventLevel (Enum.Parse (Options.DebugLogLevel)); Log.Logger = new LoggerConfiguration () .MinimumLevel.ControlledBy (LogLevelSwitch) .Enrich.FromLogContext () // Enables dynamic enrichment .WriteTo.Debug () .WriteTo.File ( LogFilePath, rollingInterval: RollingInterval.Day, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger (); // Create a logger factory compatible with Microsoft.Extensions.Logging using ILoggerFactory loggerFactory = LoggerFactory.Create ( builder => { builder .AddSerilog (dispose: true) // Integrate Serilog with ILogger .SetMinimumLevel (LogLevel.Trace); // Set minimum log level }); // Get an ILogger instance return loggerFactory.CreateLogger ("Global Logger"); } /// /// Shows the UI Catalog selection UI. When the user selects a Scenario to run, the UI Catalog main app UI is /// killed and the Scenario is run as though it were Application.TopRunnable. When the Scenario exits, this function exits. /// /// private static Scenario RunUICatalogRunnable () { // Run UI Catalog UI. When it exits, if _selectedScenario is != null then // a Scenario was selected. Otherwise, the user wants to quit UI Catalog. // If the user specified a driver on the command line then use it, // ignoring Config files. Application.Init (driverName: _forceDriver); _uiCatalogDriver = Application.Driver!.GetName (); Application.Run (); Application.Shutdown (); VerifyObjectsWereDisposed (); return UICatalogRunnable.CachedSelectedScenario!; } [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] private static readonly FileSystemWatcher _currentDirWatcher = new (); [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] private static readonly FileSystemWatcher _homeDirWatcher = new (); private static void StartConfigWatcher () { // Set up a file system watcher for `./.tui/` _currentDirWatcher.NotifyFilter = NotifyFilters.LastWrite; string assemblyLocation = Assembly.GetExecutingAssembly ().Location; string tuiDir; if (!string.IsNullOrEmpty (assemblyLocation)) { var assemblyFile = new FileInfo (assemblyLocation); tuiDir = Path.Combine (assemblyFile.Directory!.FullName, ".tui"); } else { tuiDir = Path.Combine (AppContext.BaseDirectory, ".tui"); } if (!Directory.Exists (tuiDir)) { Directory.CreateDirectory (tuiDir); } _currentDirWatcher.Path = tuiDir; _currentDirWatcher.Filter = "*config.json"; // Set up a file system watcher for `~/.tui/` _homeDirWatcher.NotifyFilter = NotifyFilters.LastWrite; var f = new FileInfo (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile)); tuiDir = Path.Combine (f.FullName, ".tui"); if (!Directory.Exists (tuiDir)) { Directory.CreateDirectory (tuiDir); } _homeDirWatcher.Path = tuiDir; _homeDirWatcher.Filter = "*config.json"; _currentDirWatcher.Changed += ConfigFileChanged; //_currentDirWatcher.Created += ConfigFileChanged; _currentDirWatcher.EnableRaisingEvents = true; _homeDirWatcher.Changed += ConfigFileChanged; //_homeDirWatcher.Created += ConfigFileChanged; _homeDirWatcher.EnableRaisingEvents = true; ThemeManager.ThemeChanged += ThemeManagerOnThemeChanged; } private static void ThemeManagerOnThemeChanged (object? sender, EventArgs e) { CM.Apply (); } private static void StopConfigWatcher () { ThemeManager.ThemeChanged += ThemeManagerOnThemeChanged; _currentDirWatcher.EnableRaisingEvents = false; _currentDirWatcher.Changed -= ConfigFileChanged; _currentDirWatcher.Created -= ConfigFileChanged; _homeDirWatcher.EnableRaisingEvents = false; _homeDirWatcher.Changed -= ConfigFileChanged; _homeDirWatcher.Created -= ConfigFileChanged; } private static void ConfigFileChanged (object sender, FileSystemEventArgs e) { if (Application.TopRunnableView == null) { return; } Logging.Debug ($"{e.FullPath} {e.ChangeType} - Loading and Applying"); ConfigurationManager.Load (ConfigLocations.All); ConfigurationManager.Apply (); } private static void UICatalogMain (UICatalogCommandLineOptions options) { // By setting _forceDriver we ensure that if the user has specified a driver on the command line, it will be used // regardless of what's in a config file. Application.ForceDriver = _forceDriver = options.Driver; // If a Scenario name has been provided on the commandline // run it and exit when done. if (options.Scenario != "none") { if (!Options.DontEnableConfigurationManagement) { ConfigurationManager.Enable (ConfigLocations.All); } int item = UICatalogRunnable.CachedScenarios!.IndexOf ( UICatalogRunnable.CachedScenarios!.FirstOrDefault ( s => s.GetName () .Equals (options.Scenario, StringComparison.OrdinalIgnoreCase) )!); UICatalogRunnable.CachedSelectedScenario = (Scenario)Activator.CreateInstance (UICatalogRunnable.CachedScenarios [item].GetType ())!; BenchmarkResults? results = RunScenario (UICatalogRunnable.CachedSelectedScenario, options.Benchmark); if (results is { }) { Console.WriteLine ( JsonSerializer.Serialize ( results, new JsonSerializerOptions { WriteIndented = true })); } VerifyObjectsWereDisposed (); return; } // Benchmark all Scenarios if (options.Benchmark) { BenchmarkAllScenarios (); return; } #if DEBUG_IDISPOSABLE View.EnableDebugIDisposableAsserts = true; #endif if (!Options.DontEnableConfigurationManagement) { ConfigurationManager.Enable (ConfigLocations.All); StartConfigWatcher (); } while (RunUICatalogRunnable () is { } scenario) { #if DEBUG_IDISPOSABLE VerifyObjectsWereDisposed (); // Measure how long it takes for the app to shut down var sw = new Stopwatch (); string scenarioName = scenario.GetName (); Application.InitializedChanged += ApplicationOnInitializedChanged; #endif Application.ForceDriver = _forceDriver; scenario.Main (); scenario.Dispose (); // This call to Application.Shutdown brackets the Application.Init call // made by Scenario.Init() above if (Application.Driver is { }) { Application.Shutdown (); } VerifyObjectsWereDisposed (); #if DEBUG_IDISPOSABLE Application.InitializedChanged -= ApplicationOnInitializedChanged; void ApplicationOnInitializedChanged (object? sender, EventArgs e) { if (e.Value) { sw.Start (); _scenarioDriver = Application.Driver!.GetName (); Debug.Assert (_scenarioDriver == _uiCatalogDriver); } else { sw.Stop (); Logging.Trace ($"Shutdown of {scenarioName} Scenario took {sw.ElapsedMilliseconds}ms"); } } #endif } StopConfigWatcher (); VerifyObjectsWereDisposed (); } private static BenchmarkResults? RunScenario (Scenario scenario, bool benchmark) { if (benchmark) { scenario.StartBenchmark (); } Application.ForceDriver = _forceDriver!; scenario.Main (); BenchmarkResults? results = null; if (benchmark) { results = scenario.EndBenchmark (); } scenario.Dispose (); if (Application.Driver is { }) { Application.Shutdown (); } return results; } private static void BenchmarkAllScenarios () { List resultsList = []; var maxScenarios = 5; foreach (Scenario s in UICatalogRunnable.CachedScenarios!) { resultsList.Add (RunScenario (s, true)!); maxScenarios--; if (maxScenarios == 0) { // break; } } if (resultsList.Count <= 0) { return; } if (!string.IsNullOrEmpty (Options.ResultsFile)) { string output = JsonSerializer.Serialize ( resultsList, new JsonSerializerOptions { WriteIndented = true }); using StreamWriter file = File.CreateText (Options.ResultsFile); file.Write (output); file.Close (); return; } Application.Init (); var benchmarkWindow = new Window { Title = "Benchmark Results" }; if (benchmarkWindow.Border is { }) { benchmarkWindow.Border!.Thickness = new (0, 0, 0, 0); } TableView resultsTableView = new () { Width = Dim.Fill (), Height = Dim.Fill () }; // TableView provides many options for table headers. For simplicity we turn all // of these off. By enabling FullRowSelect and turning off headers, TableView looks just // like a ListView resultsTableView.FullRowSelect = true; resultsTableView.Style.ShowHeaders = true; resultsTableView.Style.ShowHorizontalHeaderOverline = false; resultsTableView.Style.ShowHorizontalHeaderUnderline = true; resultsTableView.Style.ShowHorizontalBottomline = false; resultsTableView.Style.ShowVerticalCellLines = true; resultsTableView.Style.ShowVerticalHeaderLines = true; /* By default, TableView lays out columns at render time and only * measures y rows of data at a time. Where y is the height of the * console. This is for the following reasons: * * - Performance, when tables have a large amount of data * - Defensive, prevents a single wide cell value pushing other * columns off-screen (requiring horizontal scrolling * * In the case of UICatalog here, such an approach is overkill so * we just measure all the data ourselves and set the appropriate * max widths as ColumnStyles */ //int longestName = _scenarios!.Max (s => s.GetName ().Length); //resultsTableView.Style.ColumnStyles.Add ( // 0, // new () { MaxWidth = longestName, MinWidth = longestName, MinAcceptableWidth = longestName } // ); //resultsTableView.Style.ColumnStyles.Add (1, new () { MaxWidth = 1 }); //resultsTableView.CellActivated += ScenarioView_OpenSelectedItem; // TableView typically is a grid where nav keys are biased for moving left/right. resultsTableView.KeyBindings.Remove (Key.Home); resultsTableView.KeyBindings.Add (Key.Home, Command.Start); resultsTableView.KeyBindings.Remove (Key.End); resultsTableView.KeyBindings.Add (Key.End, Command.End); // Ideally, TableView.MultiSelect = false would turn off any keybindings for // multi-select options. But it currently does not. UI Catalog uses Ctrl-A for // a shortcut to About. resultsTableView.MultiSelect = false; var dt = new DataTable (); dt.Columns.Add (new DataColumn ("Scenario", typeof (string))); dt.Columns.Add (new DataColumn ("Duration", typeof (TimeSpan))); dt.Columns.Add (new DataColumn ("Refreshed", typeof (int))); dt.Columns.Add (new DataColumn ("LaidOut", typeof (int))); dt.Columns.Add (new DataColumn ("ClearedContent", typeof (int))); dt.Columns.Add (new DataColumn ("DrawComplete", typeof (int))); dt.Columns.Add (new DataColumn ("Updated", typeof (int))); dt.Columns.Add (new DataColumn ("Iterations", typeof (int))); foreach (BenchmarkResults r in resultsList) { dt.Rows.Add ( r.Scenario, r.Duration, r.RefreshedCount, r.LaidOutCount, r.ClearedContentCount, r.DrawCompleteCount, r.UpdatedCount, r.IterationCount ); } BenchmarkResults totalRow = new () { Scenario = "TOTAL", Duration = new (resultsList.Sum (r => r.Duration.Ticks)), RefreshedCount = resultsList.Sum (r => r.RefreshedCount), LaidOutCount = resultsList.Sum (r => r.LaidOutCount), ClearedContentCount = resultsList.Sum (r => r.ClearedContentCount), DrawCompleteCount = resultsList.Sum (r => r.DrawCompleteCount), UpdatedCount = resultsList.Sum (r => r.UpdatedCount), IterationCount = resultsList.Sum (r => r.IterationCount) }; dt.Rows.Add ( totalRow.Scenario, totalRow.Duration, totalRow.RefreshedCount, totalRow.LaidOutCount, totalRow.ClearedContentCount, totalRow.DrawCompleteCount, totalRow.UpdatedCount, totalRow.IterationCount ); dt.DefaultView.Sort = "Duration"; DataTable sortedCopy = dt.DefaultView.ToTable (); resultsTableView.Table = new DataTableSource (sortedCopy); benchmarkWindow.Add (resultsTableView); Application.Run (benchmarkWindow); benchmarkWindow.Dispose (); Application.Shutdown (); } private static void VerifyObjectsWereDisposed () { #if DEBUG_IDISPOSABLE if (!View.EnableDebugIDisposableAsserts) { View.Instances.Clear (); return; } // Validate there are no outstanding View instances // after a scenario was selected to run. This proves the main UI Catalog // 'app' closed cleanly. foreach (View? inst in View.Instances) { Debug.Assert (inst.WasDisposed); } View.Instances.Clear (); #endif } }