Skip to content

Commit

Permalink
Update showkeys to have multiple input methods
Browse files Browse the repository at this point in the history
  • Loading branch information
scottbilas committed May 4, 2024
1 parent 98bfba1 commit 45e0d64
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 24 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ Symbolicator, query, and conversion tool for Process Monitor ([procmon](https://

## showkeys (Showkeys.Cli)

Showkeys for Windows. Virtual terminal sequences. Useful when debugging TUI apps on Windows. Finished.
Showkeys-ish for Windows. Prints virtual terminal sequences for keypresses. Useful when debugging TUI apps on Windows. Finished, but I drop in to tweak it sometimes.

[CLI docs](src/Showkeys.Cli/Program.cs#9)

## stale (Stale.Cli)

Expand Down
208 changes: 208 additions & 0 deletions src/Core/StreamableBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
using System.Buffers;
using System.Diagnostics;

namespace OkTools.Core;

// TODO: consider deleting this class in favor of System.IO.Pipelines.Pipe (move useful methods from here to extensions on Pipe)
[PublicAPI]
public class StreamableBuffer<T> : IBufferWriter<T> where T : struct
{
T[] _buffer;
int _read, _write;

public StreamableBuffer(int capacity = 128)
{ _buffer = new T[capacity]; }

// terminology:
//
// * writable: how much space is available to write to without a realloc (write functions that take a size or items will realloc on demand)
// * unread: how much data has been written that is available to read

public int TotalCapacity => _buffer.Length;

public bool HasUnread => _write != _read;
public int UnreadCount => _write - _read;
public Memory<T> UnreadMemory => new(_buffer, _read, UnreadCount);
public Span<T> UnreadSpan => new(_buffer, _read, UnreadCount);

public Span<T> GetUnreadSpan(int size)
{ ThrowIfUnreadLessThan(size); return new(_buffer, _read, size); }
public Memory<T> GetUnreadMemory(int size)
{ ThrowIfUnreadLessThan(size); return new(_buffer, _read, size); }

public bool HasWritable => _write != _buffer.Length;
public int WritableCount => _buffer.Length - _write;
public Memory<T> WritableMemory => new(_buffer, _write, WritableCount);
public Span<T> WritableSpan => new(_buffer, _write, WritableCount);

public Span<T> GetWritableSpan(int size)
{
if (WritableCount < size)
ExpandByAtLeast(size);
return new(_buffer, _write, size);
}

public Memory<T> GetWritableMemory(int size)
{
if (WritableCount < size)
ExpandByAtLeast(size);
return new(_buffer, _write, size);
}

public void Reset()
{
_read = _write = 0;
}

public void Reset(int capacity)
{
Reset();
if (TotalCapacity != capacity)
_buffer = new T[capacity];
}

public void Write(ReadOnlyMemory<T> memory)
{
memory.Span.CopyTo(GetWritableSpan(memory.Length));
_write += memory.Length;
}

public void Write(T value)
{
if (WritableCount < 1)
ExpandByAtLeast(1);
_buffer[_write++] = value;
}

public void AdvanceWriter(int count)
{
if (WritableCount < count)
throw new InvalidOperationException("Can't advance writer past the end");
_write += count;
}

public T Read()
{ ThrowIfUnreadLessThan(); return _buffer[_read++]; }
public T Peek()
{ ThrowIfUnreadLessThan(); return _buffer[_read]; }
public T TryPeek(T defValue) =>
HasUnread ? _buffer[_read] : defValue;
public T? TryPeek() =>
HasUnread ? _buffer[_read] : null;

public void SeekReader(int offset)
{
var newOffset = _read + offset;
if (newOffset < 0)
throw new ArgumentOutOfRangeException(nameof(offset), "Can't rewind past the beginning");
if (newOffset > _write)
throw new ArgumentOutOfRangeException(nameof(offset), "Can't skip past the end");
_read += offset;
}

public void AdvanceReader()
{
if (_read == _write)
throw new InvalidOperationException("Can't advance reader past the end");
++_read;
}

void ThrowIfUnreadLessThan(int count = 1)
{
if (UnreadCount < count)
throw new OverflowException($"Insufficient data to read, need {count} but have {UnreadCount}");
}

void ExpandByAtLeast(int count)
{
var newCapacity = _buffer.Length;
while (_write + count > newCapacity)
newCapacity += Math.Max(newCapacity / 2, 1);

Debug.Assert(newCapacity != _buffer.Length);
Array.Resize(ref _buffer, newCapacity);
}

// TODO: change these from explicit to public, and align the rest of the API with it.
// TODO: also consider implementing Stream (possibly via an AsStream() given the API is old and may not fit well)

void IBufferWriter<T>.Advance(int count) => SeekReader(count);
Memory<T> IBufferWriter<T>.GetMemory(int sizeHint) => GetWritableMemory(sizeHint);
Span<T> IBufferWriter<T>.GetSpan(int sizeHint) => GetWritableSpan(sizeHint);
}

public static class StreamableBufferExtensions
{
// TODO: consider moving this to *SpanExtensions. except lose the nicer exception from GetUnreadSpan

public static void Write<T>(this StreamableBuffer<T> @this, ReadOnlySequence<T> sequence) where T : struct
{
foreach (var segment in sequence)
@this.Write(segment);
}

public static (T a, T b) Peek2<T>(this StreamableBuffer<T> @this) where T : struct
{
var span = @this.GetUnreadSpan(2);
return (span[0], span[1]);
}

public static (T a, T b, T c) Peek3<T>(this StreamableBuffer<T> @this) where T : struct
{
var span = @this.GetUnreadSpan(3);
return (span[0], span[1], span[2]);
}

// TODO: yeah these could go into span extensions

public static (T a, T b) TryPeek2<T>(this StreamableBuffer<T> @this, T defValue) where T : struct
{
var span = @this.UnreadSpan;
return span.Length switch
{
0 => (defValue, defValue),
1 => (span[0], defValue),
_ => (span[0], span[1])
};
}

public static (T a, T b, T c) TryPeek3<T>(this StreamableBuffer<T> @this, T defValue) where T : struct
{
var span = @this.UnreadSpan;
return span.Length switch
{
0 => (defValue, defValue, defValue),
1 => (span[0], defValue, defValue),
2 => (span[0], span[1], defValue),
_ => (span[0], span[1], span[2])
};
}

public static (T a, T b)? TryPeek2<T>(this StreamableBuffer<T> @this) where T : struct
{
var span = @this.UnreadSpan;
if (span.Length >= 2)
return (span[0], span[1]);
return null;
}

public static (T a, T b, T c)? TryPeek3<T>(this StreamableBuffer<T> @this) where T : struct
{
var span = @this.UnreadSpan;
if (span.Length >= 3)
return (span[0], span[1], span[2]);
return null;
}

// helpful for parsing
public static bool AdvanceIfNextEquals<T>(StreamableBuffer<T> @this, T value)
where T : struct, IEquatable<T>
{
var unread = @this.UnreadSpan;
if (unread.Length == 0 || !value.Equals(unread[0]))
return false;

@this.AdvanceReader();
return true;
}
}
144 changes: 121 additions & 23 deletions src/Showkeys.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,41 +1,139 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using DocoptNet;
using OkTools.Core.Terminal;
using Vezel.Cathode.Text.Control;
using static Vezel.Cathode.Terminal;

OutLine("Ctrl-C to quit");
OutLine();
const string help = """
showkeys, the terminal utils thingy

EnableRawMode();
Usage:
showkeys show [--dotnet | --ansi]
showkeys drive
showkeys --help

try
Commands:
show Print out the ansi keycodes of the keys you press.
drive Exercise the terminal to see what different vt100 sequences do.

Options:
--dotnet Use the built-in dotnet Console key reader in cooked mode, rather than Cathode (ansi sequences, raw mode).
--ansi Use OkTools.Core.Terminal.AnsiInputReaderReader to receive key events.
""";

var cb = new ControlBuilder();

void Flush()
{
Out(cb.Span);
cb.Clear();
}

return Docopt.CreateParser(help).Parse(args) switch
{
var queue = new BlockingCollection<byte>();
IArgumentsResult<IDictionary<string, ArgValue>> result
=> (int)await Run(result.Arguments),
IHelpResult
=> ShowHelp(),
IInputErrorResult error
=> ShowError(error.Usage),
var unknown
=> throw new SwitchExpressionException(unknown)
};

int ShowHelp() { OutLine(help); return (int)CliExitCode.Success; }
int ShowError(string usage) { ErrorLine(usage); return (int)CliExitCode.ErrorUsage; }

var task = Task.Run(() =>
async Task<CliExitCode> Run(IDictionary<string, ArgValue> options)
{
OutLine("Ctrl-C to quit");
OutLine();

try
{
var array = new byte[1];
for (;;)
if (options["show"].IsTrue)
{
Read(array);
queue.Add(array[0]);
if (options["--dotnet"].IsTrue)
return ShowKeysDotnet();

EnableRawMode();

return options["--ansi"].IsTrue
? await ShowKeysParserRaw()
: ShowKeysCathodeRaw();
}

// ReSharper disable once FunctionNeverReturns
});
if (options["drive"].IsTrue)
return await Drive();

throw new InvalidOperationException("Invalid command");
}
catch (CliErrorException ex)
{
return ex.Code;
}
finally
{
cb.Clear();
cb.SoftReset();
Flush();

DisableRawMode();
}
}

#pragma warning disable RS0030 // bypass Console.* analyzers
CliExitCode ShowKeysDotnet()
{
Console.CancelKeyPress += (_, _) => Environment.Exit((int)UnixSignal.KeyboardInterrupt.AsCliExitCode());

for (;;)
{
var keyInfo = Console.ReadKey(true);

OutLine($"key={keyInfo.Key} char='{CharUtils.ToNiceString(keyInfo.KeyChar)}' mod={keyInfo.Modifiers}");
}
}
#pragma warning restore RS0030

for (var next = 0; next != 3; )
CliExitCode ShowKeysCathodeRaw()
{
var buffer = new byte[100];
for (;;)
{
Out((next = queue.Take()) switch
var read = Read(buffer);
for (var i = 0; i < read; ++i)
{
3 => "^C",
0x1b => "^[",
'\r' => "\n",
_ => ((char)next).ToString()
});
var c = (char)buffer[i];
Out(CharUtils.ToNiceString(c));

if (c == ControlConstants.ETX)
return UnixSignal.KeyboardInterrupt.AsCliExitCode();
}
Out("\r\n");
}
}

async Task<CliExitCode> ShowKeysParserRaw()
{
await foreach (var item in AnsiInput.SelectReadKeysAsync(TerminalIn))
{
await OutAsync($"{item}\r\n");
if (item is { Char: 'c', Ctrl: true })
return UnixSignal.KeyboardInterrupt.AsCliExitCode();
}

await task;
return CliExitCode.Success;
}
finally

async Task<CliExitCode> Drive()
{
DisableRawMode();
OutLine("Ctrl-C to quit");
OutLine();

EnableRawMode();

await Task.Delay(0);

return CliExitCode.Success;
}
5 changes: 5 additions & 0 deletions src/Showkeys.Cli/Showkeys.Cli.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OkFeatures>Core;Docopt</OkFeatures></PropertyGroup>
<Import Project="$(OkTargetsRoot)Exe.targets" />

<PropertyGroup>
<AssemblyName>showkeys</AssemblyName>
<RuntimeIdentifiers>win-x64;linux-x64;osx-arm64</RuntimeIdentifiers>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Core.Terminal\Core.Terminal.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Vezel.Cathode" />
</ItemGroup>
Expand Down

0 comments on commit 45e0d64

Please sign in to comment.