diff --git a/README.md b/README.md index 0aae501..22418e1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/Core/StreamableBuffer.cs b/src/Core/StreamableBuffer.cs new file mode 100644 index 0000000..6e65ac5 --- /dev/null +++ b/src/Core/StreamableBuffer.cs @@ -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 : IBufferWriter 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 UnreadMemory => new(_buffer, _read, UnreadCount); + public Span UnreadSpan => new(_buffer, _read, UnreadCount); + + public Span GetUnreadSpan(int size) + { ThrowIfUnreadLessThan(size); return new(_buffer, _read, size); } + public Memory GetUnreadMemory(int size) + { ThrowIfUnreadLessThan(size); return new(_buffer, _read, size); } + + public bool HasWritable => _write != _buffer.Length; + public int WritableCount => _buffer.Length - _write; + public Memory WritableMemory => new(_buffer, _write, WritableCount); + public Span WritableSpan => new(_buffer, _write, WritableCount); + + public Span GetWritableSpan(int size) + { + if (WritableCount < size) + ExpandByAtLeast(size); + return new(_buffer, _write, size); + } + + public Memory 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 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.Advance(int count) => SeekReader(count); + Memory IBufferWriter.GetMemory(int sizeHint) => GetWritableMemory(sizeHint); + Span IBufferWriter.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(this StreamableBuffer @this, ReadOnlySequence sequence) where T : struct + { + foreach (var segment in sequence) + @this.Write(segment); + } + + public static (T a, T b) Peek2(this StreamableBuffer @this) where T : struct + { + var span = @this.GetUnreadSpan(2); + return (span[0], span[1]); + } + + public static (T a, T b, T c) Peek3(this StreamableBuffer @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(this StreamableBuffer @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(this StreamableBuffer @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(this StreamableBuffer @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(this StreamableBuffer @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(StreamableBuffer @this, T value) + where T : struct, IEquatable + { + var unread = @this.UnreadSpan; + if (unread.Length == 0 || !value.Equals(unread[0])) + return false; + + @this.AdvanceReader(); + return true; + } +} diff --git a/src/Showkeys.Cli/Program.cs b/src/Showkeys.Cli/Program.cs index 8074c01..9c5bde1 100644 --- a/src/Showkeys.Cli/Program.cs +++ b/src/Showkeys.Cli/Program.cs @@ -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(); + IArgumentsResult> 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 Run(IDictionary 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 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 Drive() { - DisableRawMode(); + OutLine("Ctrl-C to quit"); + OutLine(); + + EnableRawMode(); + + await Task.Delay(0); + + return CliExitCode.Success; } diff --git a/src/Showkeys.Cli/Showkeys.Cli.csproj b/src/Showkeys.Cli/Showkeys.Cli.csproj index ee65dc9..46aec48 100644 --- a/src/Showkeys.Cli/Showkeys.Cli.csproj +++ b/src/Showkeys.Cli/Showkeys.Cli.csproj @@ -1,10 +1,15 @@ + Core;Docopt showkeys + win-x64;linux-x64;osx-arm64 + + +