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
}
}