Add support for fake input in asciicast recordings

* Fixes a bug with `SelectionPrompt` and page size.
* Allow `IAnsiConsoleInput` to return `null`.
This commit is contained in:
Patrik Svensson
2021-05-24 02:36:14 +02:00
committed by Phil Scott
parent 46abadaccb
commit 450d87f5d3
56 changed files with 1314 additions and 1166 deletions

View File

@ -0,0 +1,33 @@
using System;
using Spectre.Console;
using Generator.Commands;
using System.Threading;
namespace DocExampleGenerator
{
internal static class AnsiConsoleExtensions
{
/// <summary>
/// Displays something via AnsiConsole, waits a bit and then simulates typing based on the input. If the console
/// doesn't have the focus this will just type into whatever window does so watch the alt-tab.
/// </summary>
/// <param name="console"></param>
/// <param name="action">The display action.</param>
/// <param name="input">The characters to type. ↑ for an up arrow, ↓ for down arrow, ↲ for a return and ¦ for a pause.</param>
/// <param name="initialDelayMs">How long to delay before typing. This should be at least 100ms because we won't check if the prompt has displayed before simulating typing.</param>
/// <param name="keypressDelayMs">Delay between keypresses. There will be a bit of randomness between each keypress +/- 20% of this value.</param>
public static void DisplayThenType(this IAnsiConsole console, Action<IAnsiConsole> action, string input, int initialDelayMs = 500, int keypressDelayMs = 200)
{
if (console is not AsciiCastConsole asciiConsole)
{
throw new InvalidOperationException("Not an ASCII cast console");
}
asciiConsole.Input.PushText(input, keypressDelayMs);
Thread.Sleep(initialDelayMs);
action(console);
}
}
}

View File

@ -0,0 +1,40 @@
using System;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Generator.Commands
{
public sealed class AsciiCastConsole : IAnsiConsole
{
private readonly IAnsiConsole _console;
private readonly AsciiCastInput _input;
public Profile Profile => _console.Profile;
public IAnsiConsoleCursor Cursor => _console.Cursor;
IAnsiConsoleInput IAnsiConsole.Input => _input;
public AsciiCastInput Input => _input;
public IExclusivityMode ExclusivityMode => _console.ExclusivityMode;
public RenderPipeline Pipeline => _console.Pipeline;
public AsciiCastConsole(IAnsiConsole console)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_input = new AsciiCastInput();
}
public void Clear(bool home)
{
_console.Clear(home);
}
public void Write(IRenderable renderable)
{
_console.Write(renderable);
}
}
}

View File

@ -0,0 +1,15 @@
using Spectre.Console;
namespace Generator.Commands
{
public static class AsciiCastExtensions
{
public static AsciiCastOut WrapWithAsciiCastRecorder(this IAnsiConsole ansiConsole)
{
AsciiCastOut castRecorder = new(ansiConsole.Profile.Out);
ansiConsole.Profile.Out = castRecorder;
return castRecorder;
}
}
}

View File

@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Spectre.Console;
namespace Generator.Commands
{
public sealed class AsciiCastInput : IAnsiConsoleInput
{
private readonly Queue<(ConsoleKeyInfo?, int)> _input;
private readonly Random _random = new Random();
public AsciiCastInput()
{
_input = new Queue<(ConsoleKeyInfo?, int)>();
}
public void PushText(string input, int keypressDelayMs)
{
if (input is null)
{
throw new ArgumentNullException(nameof(input));
}
foreach (var character in input)
{
PushCharacter(character, keypressDelayMs);
}
}
public void PushTextWithEnter(string input, int keypressDelayMs)
{
PushText(input, keypressDelayMs);
PushKey(ConsoleKey.Enter, keypressDelayMs);
}
public void PushCharacter(char input, int keypressDelayMs)
{
var delay = keypressDelayMs + _random.Next((int)(keypressDelayMs * -.2), (int)(keypressDelayMs * .2));
switch (input)
{
case '↑':
PushKey(ConsoleKey.UpArrow, keypressDelayMs);
break;
case '↓':
PushKey(ConsoleKey.DownArrow, keypressDelayMs);
break;
case '↲':
PushKey(ConsoleKey.Enter, keypressDelayMs);
break;
case '¦':
_input.Enqueue((null, delay));
break;
default:
var control = char.IsUpper(input);
_input.Enqueue((new ConsoleKeyInfo(input, (ConsoleKey)input, false, false, control), delay));
break;
}
}
public void PushKey(ConsoleKey input, int keypressDelayMs)
{
var delay = keypressDelayMs + _random.Next((int)(keypressDelayMs * -.2), (int)(keypressDelayMs * .2));
_input.Enqueue((new ConsoleKeyInfo((char)input, input, false, false, false), delay));
}
public ConsoleKeyInfo? ReadKey(bool intercept)
{
if (_input.Count == 0)
{
throw new InvalidOperationException("No input available.");
}
var result = _input.Dequeue();
Thread.Sleep(result.Item2);
return result.Item1;
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
@ -11,7 +12,7 @@ namespace Generator.Commands
private sealed class AsciiCastWriter : TextWriter
{
private readonly TextWriter _wrappedTextWriter;
private readonly StringBuilder _builder = new();
private readonly StringBuilder _builder = new StringBuilder();
private int? _firstTick;
public AsciiCastWriter(TextWriter wrappedTextWriter)
@ -47,7 +48,9 @@ namespace Generator.Commands
tick /= 1000m;
_builder.Append('[').Append(tick).Append(", \"o\", \"").Append(JsonEncodedText.Encode(value)).AppendLine("\"]");
_builder.Append('[')
.AppendFormat(CultureInfo.InvariantCulture, "{0}", tick)
.Append(", \"o\", \"").Append(JsonEncodedText.Encode(value)).AppendLine("\"]");
}
public string GetJsonAndClearBuffer()
@ -66,7 +69,7 @@ namespace Generator.Commands
public AsciiCastOut(IAnsiConsoleOutput wrappedAnsiConsole)
{
_wrappedAnsiConsole = wrappedAnsiConsole;
_wrappedAnsiConsole = wrappedAnsiConsole ?? throw new ArgumentNullException(nameof(wrappedAnsiConsole));
_asciiCastWriter = new AsciiCastWriter(_wrappedAnsiConsole.Writer);
}
@ -85,20 +88,8 @@ namespace Generator.Commands
public string GetCastJson(string title, int? width = null, int? height = null)
{
var header = $"{{\"version\": 2, \"width\": {width ?? _wrappedAnsiConsole.Width}, \"height\": {height ?? _wrappedAnsiConsole.Height}, \"title\": \"{JsonEncodedText.Encode(title)}\", \"env\": {{\"TERM\": \"Spectre.Console\"}}}}";
return $"{header}{Environment.NewLine}{_asciiCastWriter.GetJsonAndClearBuffer()}{Environment.NewLine}";
}
}
public static class AsciiCastExtensions
{
public static AsciiCastOut WrapWithAsciiCastRecorder(this IAnsiConsole ansiConsole)
{
AsciiCastOut castRecorder = new(ansiConsole.Profile.Out);
ansiConsole.Profile.Out = castRecorder;
return castRecorder;
}
}
}

View File

@ -8,7 +8,7 @@ namespace Generator.Commands.Samples
public abstract class BaseSample
{
public abstract void Run(IAnsiConsole console);
public virtual string Name() => PascalToKebab(this.GetType().Name.Replace("Sample",""));
public virtual string Name() => PascalToKebab(GetType().Name.Replace("Sample",""));
public virtual (int Cols, int Rows) ConsoleSize => (82, 24);
public virtual IEnumerable<(string Name, Action<Capabilities> CapabilitiesAction)> GetCapabilities()
{

View File

@ -1,4 +1,3 @@
using DocExampleGenerator;
using SixLabors.ImageSharp.Processing;
using Spectre.Console;

View File

@ -19,7 +19,6 @@ namespace Generator.Commands.Samples
console.DisplayThenType(c => password = AskPassword(c), "hunter2↲");
console.DisplayThenType(c => color = AskColor(c), "↲");
AnsiConsole.Render(new Rule("[yellow]Results[/]").RuleStyle("grey").LeftAligned());
AnsiConsole.Render(new Table().AddColumns("[grey]Question[/]", "[grey]Answer[/]")
.RoundedBorder()

View File

@ -14,7 +14,7 @@ namespace Generator.Commands.Samples
private static void AskFruit(IAnsiConsole console)
{
var favorites = AnsiConsole.Prompt(
var favorites = console.Prompt(
new MultiSelectionPrompt<string>()
.PageSize(10)
.Title("What are your [green]favorite fruits[/]?")

View File

@ -1,4 +1,3 @@
using System;
using System.IO;
using Generator.Models;
using Scriban;

View File

@ -1,62 +0,0 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using WindowsInput;
using WindowsInput.Native;
using Spectre.Console;
namespace DocExampleGenerator
{
internal static class ConsoleExtensions
{
/// <summary>
/// Displays something via AnsiConsole, waits a bit and then simulates typing based on the input. If the console
/// doesn't have the focus this will just type into whatever window does so watch the alt-tab.
/// </summary>
/// <param name="console"></param>
/// <param name="action">The display action.</param>
/// <param name="input">The characters to type. ↑ for an up arrow, ↓ for down arrow, ↲ for a return and ¦ for a pause.</param>
/// <param name="initialDelayMs">How long to delay before typing. This should be at least 100ms because we won't check if the prompt has displayed before simulating typing.</param>
/// <param name="keypressDelayMs">Delay between keypresses. There will be a bit of randomness between each keypress +/- 20% of this value.</param>
public static void DisplayThenType(this IAnsiConsole console, Action<IAnsiConsole> action, string input, int initialDelayMs = 500, int keypressDelayMs = 200)
{
if (initialDelayMs < 100)
{
throw new ArgumentOutOfRangeException(nameof(initialDelayMs), "Initial delay must be greater than 100");
}
var random = new Random(Environment.TickCount);
var inputTask = Task.Run(() => action(console));
var typingTask = Task.Run(async () =>
{
await Task.Delay(initialDelayMs);
var inputSimulator = new InputSimulator();
foreach (var character in input)
{
switch (character)
{
case '↑':
inputSimulator.Keyboard.KeyPress(VirtualKeyCode.UP);
break;
case '↓':
inputSimulator.Keyboard.KeyPress(VirtualKeyCode.DOWN);
break;
case '↲':
inputSimulator.Keyboard.KeyPress(VirtualKeyCode.RETURN);
break;
case '¦':
await Task.Delay(keypressDelayMs + random.Next((int) (keypressDelayMs * -.2), (int) (keypressDelayMs * .2)));
break;
default:
inputSimulator.Keyboard.TextEntry(character);
break;
}
await Task.Delay(keypressDelayMs + random.Next((int) (keypressDelayMs * -.2), (int) (keypressDelayMs * .2)));
}
});
Task.WaitAll(inputTask, typingTask);
}
}
}

View File

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

View File

@ -14,10 +14,11 @@ namespace Generator.Commands
{
public class Settings : CommandSettings
{
public Settings(string outputPath, string sample)
public Settings(string outputPath, string sample, bool list)
{
Sample = sample;
OutputPath = outputPath ?? Environment.CurrentDirectory;
List = list;
}
[CommandArgument(0, "[sample]")]
@ -25,35 +26,46 @@ namespace Generator.Commands
[CommandOption("-o|--output")]
public string OutputPath { get; }
[CommandOption("-l|--list")]
public bool List { get; }
}
private readonly IAnsiConsole _console;
public SampleCommand(IAnsiConsole console)
{
this._console = console;
_console = new AsciiCastConsole(console);
}
public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
{
_console.Prompt(new ConfirmationPrompt("Some commands will mimic user input. Make sure this window has focus and press y"));
var samples = typeof(BaseSample).Assembly
.GetTypes()
.Where(i => i.IsClass && i.IsAbstract == false && i.IsSubclassOf(typeof(BaseSample)))
.Select(Activator.CreateInstance)
.Cast<BaseSample>();
if (!string.IsNullOrWhiteSpace(settings.Sample))
var selectedSample = settings.Sample;
if (settings.List)
{
var desiredSample = samples.FirstOrDefault(i => i.Name().Equals(settings.Sample));
selectedSample = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select an example to record")
.PageSize(25)
.AddChoices(samples.Select(x => x.Name())));
}
if (!string.IsNullOrWhiteSpace(selectedSample))
{
var desiredSample = samples.FirstOrDefault(i => i.Name().Equals(selectedSample, StringComparison.OrdinalIgnoreCase));
if (desiredSample == null)
{
_console.MarkupLine($"[red]Error:[/] could not find sample [blue]{settings.Sample}[/]");
_console.MarkupLine($"[red]Error:[/] could not find sample [blue]{selectedSample}[/]");
return -1;
}
samples = new List<BaseSample> { desiredSample};
samples = new List<BaseSample> { desiredSample };
}
// from here on out everything we write will be recorded.

View File

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using Generator.Models;