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

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    No status

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions