mirror of
https://github.com/nsnail/spectre.console.git
synced 2025-06-19 05:18:16 +08:00
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:

committed by
Phil Scott

parent
46abadaccb
commit
450d87f5d3
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
@ -1,4 +1,3 @@
|
||||
using DocExampleGenerator;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Spectre.Console;
|
||||
|
@ -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()
|
@ -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[/]?")
|
@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Generator.Models;
|
||||
using Scriban;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
@ -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.
|
||||
|
@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Generator.Models;
|
||||
|
Reference in New Issue
Block a user