Skip to content

UICatalog and Scenario do not support modern IApplication arch #4417

@tig

Description

@tig

Problems

  1. Testing locked to legacy static API: Current tests can't determine if examples use Application (legacy) or IApplication (modern), blocking migration
  2. Not copy/paste ready: Examples wrapped in Scenario.Main() with artificial inheritance
  3. Class-based architecture unnecessary: All scenarios can be standalone programs with a

Proposal: Restructure Scenarios as Standalone Programs

Summary

Transform Terminal.Gui examples from class-based Scenarios into standalone programs with:

  • Zero cruft: No test-specific code in examples
  • Copy/paste ready: Complete, runnable programs
  • Hybrid execution: In-process (debugging) or out-of-process (isolation)
  • Declarative metadata: Assembly attributes for discovery and testing

Solution Architecture

1. Example Metadata Attributes

Location: Terminal.Gui library, Terminal.Gui.Examples namespace

[AttributeUsage(AttributeTargets.Assembly)]
public class ExampleMetadataAttribute : Attribute
{
    public ExampleMetadataAttribute(string name, string description);
    public string Name { get; }
    public string Description { get; }
}

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public class ExampleCategoryAttribute : Attribute
{
    public ExampleCategoryAttribute(string category);
    public string Category { get; }
}

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public class ExampleDemoKeyStrokesAttribute : Attribute
{
    public string[]? KeyStrokes { get; set; }
    public string? RepeatKey { get; set; }
    public int RepeatCount { get; set; } = 1;
    public int DelayMs { get; set; } = 0;
    public int Order { get; set; } = 0;
}

Usage:

[assembly: ExampleMetadata("Character Map", "Unicode viewer")]
[assembly: ExampleCategory("Text and Formatting")]
[assembly: ExampleDemoKeyStrokes(RepeatKey = "CursorDown", RepeatCount = 200, Order = 1)]
[assembly: ExampleDemoKeyStrokes(KeyStrokes = new[] { "Shift+Tab", "B", "L" }, Order = 2)]

// Pure example code - no Scenario wrapper
Application.Init();
var top = new Window();
// ... example code ...
Application.Run(top);
Application.Shutdown();

2. Test Context Injection (Zero Cruft)

Key Insight: Framework detects environment variable during Init() and auto-wires monitoring.

public class ExampleContext
{
    public string? DriverName { get; set; } = null;
    public List<string> KeysToInject { get; set; } = new();
    public int TimeoutMs { get; set; } = 30000;
    public int MaxIterations { get; set; } = -1;
    public bool CollectMetrics { get; set; } = false;
    public ExecutionMode Mode { get; set; } = ExecutionMode.OutOfProcess;
    
    public const string EnvironmentVariableName = "TERMGUI_TEST_CONTEXT";
}

public enum ExecutionMode { OutOfProcess, InProcess }

Implementation in FakeComponentFactory.CreateInput():

public override IInput<ConsoleKeyInfo> CreateInput()
{
    var fakeInput = new FakeInput();
    
    string? contextJson = Environment.GetEnvironmentVariable(ExampleContext.EnvironmentVariableName);
    if (contextJson != null)
    {
        var context = JsonSerializer.Deserialize<ExampleContext>(contextJson);
        foreach (string keyStr in context?.KeysToInject ?? [])
        {
            if (Key.TryParse(keyStr, out Key key))
                fakeInput.AddInput(ConvertKeyToConsoleKeyInfo(key));
        }
    }
    return fakeInput;
}

Implementation in ApplicationImpl.Init():

private void SetupMetricsCollection()
{
    var metrics = new ExampleMetrics { StartTime = DateTime.UtcNow };
    
    InitializedChanged += (s, e) => {
        if (e.NewState) {
            metrics.InitializedAt = DateTime.UtcNow;
            metrics.InitializedSuccessfully = true;
        }
    };
    
    Iteration += (s, e) => metrics.IterationCount++;
    
    Exiting += (s, e) => {
        metrics.ShutdownAt = DateTime.UtcNow;
        metrics.ShutdownGracefully = true;
        Console.WriteLine($"###TERMGUI_METRICS:{JsonSerializer.Serialize(metrics)}###");
    };
}

3. Example Runner

public static class ExampleRunner
{
    public static ExampleResult Run(ExampleInfo example, ExampleContext context)
    {
        return context.Mode == ExecutionMode.InProcess 
            ? RunInProcess(example, context) 
            : RunOutOfProcess(example, context);
    }
    
    private static ExampleResult RunInProcess(ExampleInfo example, ExampleContext context)
    {
        Environment.SetEnvironmentVariable(
            ExampleContext.EnvironmentVariableName, 
            JsonSerializer.Serialize(context));
        try
        {
            Assembly asm = Assembly.LoadFrom(example.AssemblyPath);
            asm.EntryPoint?.Invoke(null, 
                asm.EntryPoint.GetParameters().Length == 0 ? null : new[] { Array.Empty<string>() });
            return new ExampleResult { Success = true };
        }
        finally
        {
            Environment.SetEnvironmentVariable(ExampleContext.EnvironmentVariableName, null);
        }
    }
    
    private static ExampleResult RunOutOfProcess(ExampleInfo example, ExampleContext context)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "dotnet",
            Arguments = $"\"{example.AssemblyPath}\"",
            UseShellExecute = false,
            RedirectStandardOutput = true,
            Environment = { [ExampleContext.EnvironmentVariableName] = JsonSerializer.Serialize(context) }
        };
        
        using var process = Process.Start(psi);
        bool exited = process.WaitForExit(context.TimeoutMs);
        
        if (!exited)
        {
            process.Kill();
            return new ExampleResult { Success = false, TimedOut = true };
        }
        
        return new ExampleResult 
        { 
            Success = process.ExitCode == 0, 
            ExitCode = process.ExitCode 
        };
    }
}

4. Example Discovery

public static class ExampleDiscovery
{
    public static IEnumerable<ExampleInfo> DiscoverFromFiles(params string[] assemblyPaths)
    {
        foreach (string path in assemblyPaths)
        {
            Assembly asm = Assembly.LoadFrom(path);
            var metadata = asm.GetCustomAttribute<ExampleMetadataAttribute>();
            if (metadata == null) continue;
            
            yield return new ExampleInfo
            {
                Name = metadata.Name,
                Description = metadata.Description,
                AssemblyPath = path,
                Categories = asm.GetCustomAttributes<ExampleCategoryAttribute>()
                    .Select(c => c.Category).ToList(),
                DemoKeyStrokes = ParseDemoKeyStrokes(asm)
            };
        }
    }
}

5. Updated Test Infrastructure

public class ExampleTests
{
    [Theory]
    [MemberData(nameof(AllExamples))]
    public void All_Examples_Quit_And_Init_Shutdown_Properly(ExampleInfo example)
    {
        var result = ExampleRunner.Run(example, new ExampleContext
        {
            DriverName = "FakeDriver",
            KeysToInject = new() { "Esc" },
            TimeoutMs = 5000,
            CollectMetrics = true,
            Mode = ExecutionMode.OutOfProcess
        });
        
        Assert.True(result.Success);
        Assert.True(result.Metrics?.InitializedSuccessfully);
        Assert.True(result.Metrics?.ShutdownGracefully);
    }
    
    public static IEnumerable<object[]> AllExamples =>
        ExampleDiscovery.DiscoverFromFiles(Directory.GetFiles("Examples", "*.dll", SearchOption.AllDirectories))
            .Select(e => new object[] { e });
}

File Organization

Examples/
  TUIExplorer/              # New browser app
  FluentExample/            # Existing, add attributes
  CharacterMap/             # Converted from Scenario
    Program.cs              # Standalone program
    CharMap.cs              # Custom view (if needed)

Migration Path

Phase 1: Infrastructure

  • Create Terminal.Gui.Examples namespace
  • Add attributes, discovery, runner, context classes
  • Update FakeComponentFactory.CreateInput() and ApplicationImpl.Init()

Phase 2: Proof of Concept

  • Update 3 existing examples (FluentExample, RunnableWrapperExample, Example)
  • Convert 5 Scenarios (Buttons, Selectors, CharacterMap, AllViewsTester, Wizards)
  • Create test infrastructure
  • Validate both execution modes

Phase 3: TUIExplorer

  • Create browser app with discovery and launching

Phase 4: Mass Migration

  • Convert ~100 remaining Scenarios
  • Update all tests

Phase 5: Deprecation

  • Remove UICatalog and Scenario

Key Benefits

For Users:

  • Run directly: dotnet run --project Examples/CharacterMap
  • Copy/paste entire Program.cs - works immediately

For Testing:

  • Works with both legacy and modern APIs
  • Zero test code in examples
  • In-process (debugging) or out-of-process (isolation)

For Maintenance:

  • Examples independently buildable
  • No tight coupling to browser app
  • Supports simple and complex scenarios

Technical Risks

Risk Mitigation
Environment variables with async/await Process-scoped, works fine. In-process tests must cleanup.
Assembly.EntryPoint invocation Well-understood pattern. Handle all Main() signatures.
Performance of loading assemblies Load on-demand. Out-of-process doesn't load in test process.
Breaking changes for contributors Scenario remains during transition. Clear migration guide.
Debugging out-of-process In-process mode available. Debugger can attach to processes.

Success Criteria

  • All examples converted to standalone programs
  • TUIExplorer provides equivalent browsing
  • All tests pass with new infrastructure
  • Both execution modes work
  • Zero test-specific code in examples
  • Examples are copy/paste ready
  • Supports both legacy and modern APIs

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

Status

🏗 Approved - In progress

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions