From c060b44b5fe287a5d6197307bfcb640e6cdb00db Mon Sep 17 00:00:00 2001 From: Scott Bilas Date: Mon, 19 Aug 2024 00:23:27 +0100 Subject: [PATCH] More iteration and simplifying --- src/Core.Terminal/KeyEvent.cs | 4 +- src/Stale.Cli/BannedSymbols.txt | 10 +- src/Stale.Cli/Context.cs | 24 ++- src/Stale.Cli/Playback.cs | 2 +- src/Stale.Cli/Screen.cs | 131 ------------ src/Stale.Cli/StaleApp.cs | 350 ++++++++++++++++++++++---------- src/Stale.Cli/StaleCli.cs | 11 +- src/Stale.Cli/StatusPane.cs | 40 ++-- 8 files changed, 291 insertions(+), 281 deletions(-) delete mode 100644 src/Stale.Cli/Screen.cs diff --git a/src/Core.Terminal/KeyEvent.cs b/src/Core.Terminal/KeyEvent.cs index c0db68a..5505e43 100644 --- a/src/Core.Terminal/KeyEvent.cs +++ b/src/Core.Terminal/KeyEvent.cs @@ -3,8 +3,10 @@ // TODO: let's also pack in a little 10-char (null-term) array to store the captured pattern, and have little helpers to access it. // sometimes might be nicer when detecting ctrl-c for example, and may help with diagnostics as well when there is a misfire on the key matcher. +public interface ITerminalEvent {} + [PublicAPI] -public readonly record struct KeyEvent(ConsoleKey Key, char Char, bool Alt = false, bool Shift = false, bool Ctrl = false) +public readonly record struct KeyEvent(ConsoleKey Key, char Char, bool Alt = false, bool Shift = false, bool Ctrl = false) : ITerminalEvent { public KeyEvent(ConsoleKey key, bool alt = false, bool shift = false, bool ctrl = false) : this(key, default, alt, shift, ctrl) {} diff --git a/src/Stale.Cli/BannedSymbols.txt b/src/Stale.Cli/BannedSymbols.txt index 671385c..e9311f9 100644 --- a/src/Stale.Cli/BannedSymbols.txt +++ b/src/Stale.Cli/BannedSymbols.txt @@ -2,14 +2,6 @@ M:Vezel.Cathode.IO.TerminalWriter.WriteLineAsync`1(``0,System.Threading.CancellationToken) M:Vezel.Cathode.IO.TerminalWriter.WriteLine`1(``0) M:Vezel.Cathode.IO.TerminalWriter.Write`1(``0) -M:Vezel.Cathode.Terminal.ErrorAsync`1(``0,System.Threading.CancellationToken) -M:Vezel.Cathode.Terminal.ErrorLineAsync`1(``0,System.Threading.CancellationToken) -M:Vezel.Cathode.Terminal.ErrorLine`1(``0) -M:Vezel.Cathode.Terminal.Error`1(``0) -M:Vezel.Cathode.Terminal.OutAsync`1(``0,System.Threading.CancellationToken) -M:Vezel.Cathode.Terminal.OutLineAsync`1(``0,System.Threading.CancellationToken) -M:Vezel.Cathode.Terminal.OutLine`1(``0) -M:Vezel.Cathode.Terminal.Out`1(``0) M:Vezel.Cathode.Text.Control.ControlBuilder.PrintLine`1(``0) M:Vezel.Cathode.Text.Control.ControlBuilder.Print`1(``0) M:Vezel.Cathode.VirtualTerminal.ErrorAsync`1(``0,System.Threading.CancellationToken) @@ -20,3 +12,5 @@ M:Vezel.Cathode.VirtualTerminal.OutAsync`1(``0,System.Threading.CancellationToke M:Vezel.Cathode.VirtualTerminal.OutLineAsync`1(``0,System.Threading.CancellationToken) M:Vezel.Cathode.VirtualTerminal.OutLine`1(``0) M:Vezel.Cathode.VirtualTerminal.Out`1(``0) + +T:Vezel.Cathode.Terminal diff --git a/src/Stale.Cli/Context.cs b/src/Stale.Cli/Context.cs index 64dcc48..d1d4056 100644 --- a/src/Stale.Cli/Context.cs +++ b/src/Stale.Cli/Context.cs @@ -43,18 +43,23 @@ public void Stop() class Context : IDisposable { readonly CancellationTokenSource _cancelSource = new(); - readonly LongTasks _longTasks; + readonly IDumpOutput _dumpOutput; - public Context() + public Context(VirtualTerminal? terminal = null) { - _longTasks = new(_cancelSource.Token); +# pragma warning disable RS0030 + Terminal = terminal ?? Vezel.Cathode.Terminal.System; +# pragma warning restore RS0030 + + LongTasks = new(_cancelSource.Token); + _dumpOutput = new TerminalDumpOutput(Terminal); } public void Dispose() { _cancelSource.Dispose(); - var stillRunning = _longTasks.GetTasksSnapshot(); + var stillRunning = LongTasks.GetTasksSnapshot(); if (stillRunning.Any(t => !t.Weak)) { if (IsVerbose || Debugger.IsAttached) @@ -84,7 +89,8 @@ public void Dispose() public bool IsCancellationRequested => _cancelSource.IsCancellationRequested; public void Cancel() => _cancelSource.Cancel(); - public LongTasks LongTasks => _longTasks; + public VirtualTerminal Terminal { get; } + public LongTasks LongTasks { get; } public StaleCliArguments Options = null!; public bool IsVerbose; // TODO: either make readonly for better JIT or force caller to check this before calling Verbose funcs @@ -187,7 +193,7 @@ void OutDump(T value, string? label, ColorConfig colors) => value.Dump( useDescriptors: false, typeNames: new() { ShowTypeNames = false }, colors: colors, - output: s_dumpOutput, + output: _dumpOutput, tableConfig: new() { ShowTableHeaders = false }); // Other @@ -198,16 +204,18 @@ void OutDump(T value, string? label, ColorConfig colors) => value.Dump( class TerminalDumpOutput : IDumpOutput { + public TerminalDumpOutput(VirtualTerminal terminal) => + TextWriter = terminal.StandardOut.TextWriter; + // i'd like to adjust the config to have a MemberProvider that skips fields that are set to their default values // but that would break the table rendering, where all rows need the same fields. would require a deeper change // to dumpify to support that. public RendererConfig AdjustConfig(in RendererConfig config) => config; - public TextWriter TextWriter { get; } = Terminal.StandardOut.TextWriter; + public TextWriter TextWriter { get; } } readonly PerfStat[] _stats = EnumUtility.GetNames().Select(_ => new PerfStat()).ToArray(); - static readonly IDumpOutput s_dumpOutput = new TerminalDumpOutput(); static readonly ColorConfig k_verboseDumpColors = new(new DumpColor("#808080")); } diff --git a/src/Stale.Cli/Playback.cs b/src/Stale.Cli/Playback.cs index 25b95ae..5e9a183 100644 --- a/src/Stale.Cli/Playback.cs +++ b/src/Stale.Cli/Playback.cs @@ -64,7 +64,7 @@ public static async Task Record(Context ctx, string? recordedPath, var jsonStream = recordedPath != null ? File.Create(recordedPath) - : Terminal.StandardOut.Stream; + : ctx.Terminal.StandardOut.Stream; await using var writer = new StreamWriter(jsonStream); writer.AutoFlush = true; diff --git a/src/Stale.Cli/Screen.cs b/src/Stale.Cli/Screen.cs deleted file mode 100644 index ff4060e..0000000 --- a/src/Stale.Cli/Screen.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Drawing; -using OkTools.Core.Terminal; -using Vezel.Cathode; -using Vezel.Cathode.Text.Control; - -public enum ScreenUpdateStatus { Ok, TerminalSizeChanged } - -class Screen : IDisposable -{ - readonly ControlBuilder _cb = new(); - bool _disposed; - - readonly VirtualTerminal _terminal; - - Size _screenSize; - Size? _pendingScreenSize; - - public Screen(VirtualTerminal terminal) - { - _terminal = terminal; - _terminal.Resized += OnResized; - _pendingScreenSize = _terminal.Size; - - Control.SoftReset(); - Control.HideCursor(); - - Update(); - } - - public ControlBuilder Control => _cb; - - public void Dispose() - { - ObjectDisposedException.ThrowIf(_disposed, this); - _disposed = true; - - // set terminal back to normal, we may have leftover state like margins or whatever - _cb.Clear(); - _cb.SoftReset(); - FlushStdout(); - - _terminal.Resized -= OnResized; - } - - public Size ScreenSize => _screenSize; - public int ScreenWidth => _screenSize.Width; - public int ScreenHeight => _screenSize.Height; - - public ScreenUpdateStatus Update() - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (_pendingScreenSize is null) - return ScreenUpdateStatus.Ok; - - _screenSize = _pendingScreenSize.Value; - _pendingScreenSize = null; - - return ScreenUpdateStatus.TerminalSizeChanged; - } - - void OnResized(Size newSize) => _pendingScreenSize = newSize; - - public async ValueTask FlushStdoutAsync(CancellationToken cancel) - { - if (_cb.Span.IsEmpty) - return; - - await _terminal.OutAsync(_cb.Memory, cancel); - _cb.Clear(10*1024); - } - - public void FlushStdout() - { - if (_cb.Span.IsEmpty) - return; - - _terminal.Out(_cb.Span); - _cb.Clear(10*1024); - } - - public void SetCursorPos(int x, int y) - { - if (x < 0 || x >= _screenSize.Width) - throw new ArgumentOutOfRangeException(nameof(x), $"Out of range 0 <= {x} < {_screenSize.Width}"); - if (y < 0 || y >= _screenSize.Height) - throw new ArgumentOutOfRangeException(nameof(y), $"Out of range 0 <= {y} < {_screenSize.Height}"); - - _cb.MoveCursorTo(y, x); - } - -# pragma warning disable CA1822, RS0030 - public Point GetCursorPos() - { - // may be lingering move ops - FlushStdout(); - - // cathode does not yet support reading cursor position. see https://github.com/vezel-dev/cathode/discussions/16 - // i get the concern, but for this app i'm ensuring that i do cursor reading at well-defined times. - var (left, top) = Console.GetCursorPosition(); - return new Point(left, top); - } -# pragma warning restore CA1822, RS0030 - - public AutoSaveRestoreCursorState SaveRestoreCursorPos() => - new(this); - public AutoSaveRestoreCursorState SaveRestoreCursorPos(int x, int y) - { - var saver = new AutoSaveRestoreCursorState(this); - SetCursorPos(x, y); - return saver; - } - - public readonly struct AutoSaveRestoreCursorState : IDisposable - { - readonly Screen _screen; - - public AutoSaveRestoreCursorState(Screen screen) - { - _screen = screen; - _screen.Control.SaveCursorState(); - _screen.Control.HideCursor(); - } - - public void Dispose() - { - _screen.Control.HideCursor(); - _screen.Control.RestoreCursorState(); - } - } -} diff --git a/src/Stale.Cli/StaleApp.cs b/src/Stale.Cli/StaleApp.cs index daf22ab..caa542e 100644 --- a/src/Stale.Cli/StaleApp.cs +++ b/src/Stale.Cli/StaleApp.cs @@ -1,9 +1,10 @@ -using System.Threading.Channels; +using System.Drawing; +using System.Threading.Channels; using OkTools.Core.Terminal; using Spectre.Console; -using Vezel.Cathode; using Vezel.Cathode.Text.Control; using Color = Spectre.Console.Color; +using Size = System.Drawing.Size; class CliExitException : Exception { @@ -28,47 +29,63 @@ public StaleOptions(StaleCliArguments args) public readonly bool PauseEveryLine; } +record ResizeEvent(Size NewSize) : ITerminalEvent; + class StaleApp : IDisposable { + bool _disposed; + readonly Context _ctx; readonly StaleOptions _options; - readonly ChildProcess _process; - readonly Screen _screen = new(Terminal.System); + readonly ControlBuilder _cb = new(); + Size _screenSize; readonly StatusPane _topStatusPane, _bottomStatusPane; - readonly Channel _events = Channel.CreateUnbounded(); + readonly ChildProcess _process; + readonly Channel _userEvents = Channel.CreateUnbounded(); int _pauseSkip; - // TODO: invalidate all panels on resize - StaleApp(Context ctx, StaleOptions options, ChildProcess process) { _ctx = ctx; // unowned _options = options; + _screenSize = _ctx.Terminal.Size; _process = process; - _topStatusPane = new StatusPane(_screen, 0, Constants.TopStatusColor); - _bottomStatusPane = new StatusPane(_screen, 0, Constants.BottomStatusColor); + _topStatusPane = new StatusPane(0, _screenSize.Width, Constants.TopStatusColor); + _bottomStatusPane = new StatusPane(0, _screenSize.Width, Constants.BottomStatusColor); _pauseSkip = _options.PauseEveryLine ? 0 : -1; + + //$$$ + _ctx.Terminal.Resized += OnResized; + + _cb.SoftReset(); + _cb.HideCursor(); } + void OnResized(Size newSize) => _userEvents.Writer.TryWrite(new ResizeEvent(newSize)); // unbounded channel will only fail to write when channel is completed + void IDisposable.Dispose() { - _screen.Control.Clear(); - _screen.Control.ResetScrollMargin(); - _screen.Control.MoveCursorTo(_bottomStatusPane.Top, 0); - _screen.Control.PrintLine(); + ObjectDisposedException.ThrowIf(_disposed, this); + _disposed = true; + + _ctx.Terminal.Resized -= OnResized; + + _cb.Clear(); + _cb.SoftReset(); // set terminal back to normal, we may have leftover state like margins or whatever + if (_bottomStatusPane.Top != 0) + _cb.MoveCursorTo(_bottomStatusPane.Top, 0); + _cb.PrintLine(); if (_ctx.IsCancellationRequested) - _screen.Control.PrintLine("User aborted!", k_userAlertStyle); + _cb.PrintLine("User aborted!", k_userAlertStyle); - _screen.FlushStdout(); + FlushStdout(); if (_ctx.IsCancellationRequested) _ctx.LongTasks.AbortAll(); - - _screen.Dispose(); } public static async Task Run(Context ctx, StaleOptions options, ChildProcess process) @@ -92,10 +109,10 @@ async Task RunInternal() { //// setup - Terminal.EnableRawMode(); + _ctx.Terminal.EnableRawMode(); // fire & forget, because ReadKeysAsync routes them into the channel and the `await foreach` will pick that up - _ctx.LongTasks.RunWeak("Stdin Reader", cancel => AnsiInput.ReadKeysAsync(Terminal.TerminalIn, _events.Writer, e => + _ctx.LongTasks.RunWeak("Stdin Reader", cancel => AnsiInput.ReadKeysAsync(_ctx.Terminal.TerminalIn, _userEvents.Writer, e => { if (e is { Modifiers: ConsoleModifiers.Control, Key: ConsoleKey.C }) _ctx.Cancel(); @@ -104,56 +121,71 @@ async Task RunInternal() if (_options.Start != null) { - _screen.Control.ClearScreen(); - _screen.SetCursorPos(0, _options.Start.Value); - await _screen.FlushStdoutAsync(_ctx.CancelToken); + _cb.ClearScreen(); + SetCursorPos(_options.Start.Value, 0); + await FlushStdoutAsync(); } - _topStatusPane.PrintLine(GetTopStatusText()); + _topStatusPane.PrintLine(_cb, GetTopStatusText()); ControlPrintEndOfText(true); - _bottomStatusPane.Print(GetBottomStatusText()); - _screen.Control.MoveCursorLineStart(); - _screen.Control.MoveCursorUp(); + _bottomStatusPane.Print(_cb, GetBottomStatusText()); + _cb.MoveCursorLineStart(); + _cb.MoveCursorUp(); - var cursorPos = _screen.GetCursorPos(); + var cursorPos = GetCursorPos(); _topStatusPane.Top = cursorPos.Y-1; _bottomStatusPane.Top = cursorPos.Y+1; //// main loop - await foreach (var capture in _process.Captures.ReadAllAsync(_ctx.CancelToken)) + var captures = new LineCapture[1000]; + var captureCount = 0; + while (!_ctx.IsCancellationRequested) { - // TODO: support incremental printing per line + ObjectDisposedException.ThrowIf(_disposed, this); - if (_ctx.IsCancellationRequested) - break; - - if (!capture.Line.IsNullOrWhiteSpace()) + while (_userEvents.Reader.TryRead(out var userEvent)) { - // TODO: line continuation markup, wrap/truncate handling, etc. - - var chars = capture.Line.AsMemory(); - while (chars.Length > _screen.ScreenWidth && !_ctx.IsCancellationRequested) + switch (userEvent) { - await OutLineAsync(capture.IsStdErr, chars[.._screen.ScreenWidth]); - chars = chars[_screen.ScreenWidth..]; + case ResizeEvent resizeEvent: + HandleResize(resizeEvent); + break; + case KeyEvent { Char: 'q', Modifiers: 0 }: + HandleUserQuit(); + break; + default: + throw new InvalidOperationException($"Unknown event type {userEvent.GetType()}"); } + } - if (chars.Length > 0) - await OutLineAsync(capture.IsStdErr, chars); + while (_process.Captures.TryRead(out var capture)) + { + captures[captureCount++] = capture; + + // if flooding, give a chance to do a render and deal with user input periodically + if (captureCount == captures.Length) + break; } - else + + if (captureCount != 0) { - // TODO: consider a flag to skip these or a sequence of n of these (to compress vertical space a bit where we have a spacey tool). - // could also consider compressing them after seeing a few lines worth (with a little margin marker) + await LogCapturesAsync(captures.WithLength(captureCount)); + captureCount = 0; + } + else if (await Task.WhenAny( + _process.Exited, + _userEvents.Reader.WaitToReadAsync(_ctx.CancelToken).AsTask(), + _process.Captures.WaitToReadAsync(_ctx.CancelToken).AsTask()) == _process.Exited) + { + // TODO: consider whether to stay running idle even after process completed, so as to permit the nice scrollback features - await OutLineAsync(capture.IsStdErr, default); + ControlPrintLogLine(OutLineType.Status, $"{_process.Command} exited normally with exit code {_process.Exited.Result}", false); + await FlushStdoutAsync(); + break; } } - if (_process.Exited.IsCompleted && !_ctx.CancelToken.IsCancellationRequested) - await _ctx.OutMarkupInterpLineAsync($"[italic]{_process.Command} exited normally with exit code {_process.Exited.Result}[/]"); - return CliExitCode.Success; } @@ -161,46 +193,60 @@ async Task RunInternal() string GetBottomStatusText() => $"...top={_topStatusPane.Top}, btm={_bottomStatusPane.Top}"; static readonly Style k_stdErrStyle = new(background: Color.DarkRed); + static readonly Style k_statusStyle = new(decoration: Decoration.Italic); static readonly Style k_userAlertStyle = new(foreground: Color.Yellow); void ControlFinishLogLine(bool newline) { - _screen.Control.ClearLine(ClearMode.After); + _cb.ClearLine(ClearMode.After); if (newline) - _screen.Control.PrintLine(); + _cb.PrintLine(); else - _screen.Control.MoveCursorLineStart(); + _cb.MoveCursorLineStart(); } - void ControlPrintLogLine(bool isStdErr, ReadOnlyMemory chars, bool newline) + void ControlPrintLogLine(OutLineType lineType, ReadOnlyMemory chars, bool newline) { - if (isStdErr) - _screen.Control.Print(chars.ToString(), k_stdErrStyle); - else - _screen.Control.Print(chars.Span); + switch (lineType) + { + case OutLineType.CapturedStdout: + _cb.Print(chars.Span); + break; + case OutLineType.CapturedStderr: + _cb.Print(chars.ToString(), k_stdErrStyle); + break; + case OutLineType.Status: + _cb.Print(chars.ToString(), k_statusStyle); + break; + default: + throw new ArgumentOutOfRangeException(nameof(lineType)); + } ControlFinishLogLine(newline); } - void ControlPrintLogLine(bool isStdErr, string text, bool newline) - { - if (isStdErr) - _screen.Control.Print(text, k_stdErrStyle); - else - _screen.Control.Print(text); - ControlFinishLogLine(newline); - } + void ControlPrintLogLine(OutLineType lineType, string text, bool newline) => + ControlPrintLogLine(lineType, text.AsMemory(), newline); // TODO: spinner + timer while child process still running - void ControlPrintEndOfText(bool newline) => ControlPrintLogLine(false, "‡", newline); + void ControlPrintEndOfText(bool newline) => ControlPrintLogLine(OutLineType.Status, "‡", newline); + + enum OutLineType { CapturedStdout, CapturedStderr, Status } - async ValueTask OutLineAsync(bool isStdErr, ReadOnlyMemory chars) + ValueTask OutLineAsync(bool stdErrOrOut, ReadOnlyMemory chars) => + OutLineAsync(stdErrOrOut ? OutLineType.CapturedStderr : OutLineType.CapturedStdout, chars); + ValueTask OutLineAsync(bool stdErrOrOut, string text) => + OutLineAsync(stdErrOrOut, text.AsMemory()); + ValueTask OutLineAsync(OutLineType lineType, string text) => + OutLineAsync(lineType, text.AsMemory()); + + async ValueTask OutLineAsync(OutLineType lineType, ReadOnlyMemory chars) { - Pause(); + await LinePauseAsync(); // check for ctrl-c with peek (need to buffer stdin on my own) - if (chars.Length > _screen.ScreenWidth) - throw new ArgumentException($"Too wide ({chars.Length}) for screen width ({_screen.ScreenWidth})"); + if (chars.Length > _screenSize.Width) + throw new ArgumentException($"Too wide ({chars.Length}) for screen width ({_screenSize.Width})"); if (_ctx.IsCancellationRequested) return; @@ -208,23 +254,23 @@ async ValueTask OutLineAsync(bool isStdErr, ReadOnlyMemory chars) // TODO: support batching output. for example shouldn't redraw status except every x msec or keep issuing unnecessary margin updates. var belowTop = _topStatusPane.Top > 0; // allow top status to scroll up without going offscreen - var aboveBottom = _bottomStatusPane.Top < _screen.ScreenHeight-1; + var aboveBottom = _bottomStatusPane.Top < _screenSize.Height-1; // not yet at bottom; push lower status down if (aboveBottom) { - _screen.Control.SetScrollMargin(_bottomStatusPane.Top, _screen.ScreenHeight-1); - _screen.Control.MoveBufferDown(); + _cb.SetScrollMargin(_bottomStatusPane.Top, _screenSize.Height-1); + _cb.MoveBufferDown(); ++_bottomStatusPane.Top; } if (belowTop || aboveBottom) { - _screen.Control.SetScrollMargin(belowTop ? 0 : 1, _bottomStatusPane.Top-1); - _screen.Control.MoveCursorTo(_bottomStatusPane.Top - (aboveBottom ? 2 : 1), 0); + _cb.SetScrollMargin(belowTop ? 0 : 1, _bottomStatusPane.Top-1); + _cb.MoveCursorTo(_bottomStatusPane.Top - (aboveBottom ? 2 : 1), 0); } - ControlPrintLogLine(isStdErr, chars, true); + ControlPrintLogLine(lineType, chars, true); ControlPrintEndOfText(false); // only if we pushed the top status up @@ -234,31 +280,52 @@ async ValueTask OutLineAsync(bool isStdErr, ReadOnlyMemory chars) if (--_topStatusPane.Top == 0) { // set the scroll region and cursor to the full area just once, and we'll leave it alone after that - _screen.Control.SetScrollMargin(1, _bottomStatusPane.Top-1); - _screen.Control.MoveCursorTo(_bottomStatusPane.Top-1, 0); + _cb.SetScrollMargin(1, _bottomStatusPane.Top-1); + _cb.MoveCursorTo(_bottomStatusPane.Top-1, 0); } } - _bottomStatusPane.Update(GetBottomStatusText()); + _bottomStatusPane.Update(_cb, GetBottomStatusText()); - await _screen.FlushStdoutAsync(_ctx.CancelToken); + await FlushStdoutAsync(); } - // ReSharper disable once UnusedMember.Local - void DebugRenderCursorPos() + async Task LogCapturesAsync(ArraySegment captures) { + // TODO: process captures as a single batch + + foreach (var capture in captures) { - using var _ = _screen.Control.AutoSaveRestoreCursorState(); - var pos = _screen.GetCursorPos(); - var text = $" {pos.X},{pos.Y}"; - _screen.SetCursorPos(_screen.ScreenWidth - text.Length, 0); - _screen.Control.Print(text); - } + // TODO: support incremental printing per line + + if (_ctx.IsCancellationRequested) + break; - _screen.FlushStdout(); + if (!capture.Line.IsNullOrWhiteSpace()) + { + // TODO: line continuation markup, wrap/truncate handling, etc. + + var chars = capture.Line.AsMemory(); + while (chars.Length > _screenSize.Width && !_ctx.IsCancellationRequested) + { + await OutLineAsync(capture.IsStdErr, chars[.._screenSize.Width]); + chars = chars[_screenSize.Width..]; + } + + if (chars.Length > 0) + await OutLineAsync(capture.IsStdErr, chars); + } + else + { + // TODO: consider a flag to skip these or a sequence of n of these (to compress vertical space a bit where we have a spacey tool). + // could also consider compressing them after seeing a few lines worth (with a little margin marker) + + await OutLineAsync(capture.IsStdErr, default(ReadOnlyMemory)); + } + } } - void Pause() + async Task LinePauseAsync() { if (_pauseSkip != 0) { @@ -269,23 +336,102 @@ void Pause() while (!_ctx.CancelToken.IsCancellationRequested) { - if (!_events.Reader.TryRead(out var evt)) + var userEvent = await _userEvents.Reader.ReadAsync(_ctx.CancelToken); + switch (userEvent) { - Thread.Sleep(10); - continue; + case ResizeEvent resizeEvent: + HandleResize(resizeEvent); + break; + + case KeyEvent keyEvent: + if (keyEvent is { Char: 'q', Modifiers: 0 }) + HandleUserQuit(); + + if (keyEvent is { Char: '\r', Modifiers: 0 }) + _pauseSkip = 0; + else if (keyEvent is { Char: ' ', Modifiers: 0 }) + _pauseSkip = 9; + else + continue; + break; + + default: + throw new InvalidOperationException($"Unknown event type {userEvent.GetType()}"); } - if (evt is { Char: 'q', Modifiers: 0 }) - throw new CliExitException("User quit"); - - if (evt is { Char: '\r', Modifiers: 0 }) - _pauseSkip = 0; - else if (evt is { Char: ' ', Modifiers: 0 }) - _pauseSkip = 9; - else - continue; - break; } } + + // ReSharper disable once UnusedMember.Local + void DebugRenderCursorPos() + { + { + using var _ = _cb.SaveRestoreCursor(); + var pos = GetCursorPos(); + var text = $" {pos.X},{pos.Y}"; + SetCursorPos(text.Length - _screenSize.Width, 0); + _cb.Print(text); + } + + FlushStdout(); + } + + void FlushStdout() + { + if (_cb.Span.IsEmpty) + return; + + _ctx.Terminal.Out(_cb.Span); + _cb.Clear(10*1024); + } + + async ValueTask FlushStdoutAsync() + { + if (_cb.Span.IsEmpty) + return; + + await _ctx.Terminal.OutAsync(_cb.Memory, _ctx.CancelToken); + _cb.Clear(10*1024); + } + + void SetCursorPos(int y, int x) + { + if (x < 0 || x >= _screenSize.Width) + throw new ArgumentOutOfRangeException(nameof(x), $"Out of range 0 <= {x} < {_screenSize.Width}"); + if (y < 0 || y >= _screenSize.Height) + throw new ArgumentOutOfRangeException(nameof(y), $"Out of range 0 <= {y} < {_screenSize.Height}"); + + _cb.MoveCursorTo(y, x); + } + + static void HandleUserQuit() + { + throw new CliExitException("User quit"); + } + + void HandleResize(ResizeEvent resizeEvent) + { + _screenSize = resizeEvent.NewSize; + _topStatusPane.MaxWidth = _screenSize.Width; + _topStatusPane.Invalidate(); + _bottomStatusPane.MaxWidth = _screenSize.Width; + _bottomStatusPane.Invalidate(); + + // TODO: react to shorter height + // TODO: re-render logview? + } + +# pragma warning disable CA1822, RS0030 + Point GetCursorPos() + { + // may be lingering move ops + FlushStdout(); + + // cathode does not yet support reading cursor position. see https://github.com/vezel-dev/cathode/discussions/16 + // i get the concern, but for this app i'm ensuring that i do cursor reading at well-defined times. + var (left, top) = Console.GetCursorPosition(); + return new Point(left, top); + } +# pragma warning restore CA1822, RS0030 } diff --git a/src/Stale.Cli/StaleCli.cs b/src/Stale.Cli/StaleCli.cs index 1e1aa7c..c9c8ebd 100644 --- a/src/Stale.Cli/StaleCli.cs +++ b/src/Stale.Cli/StaleCli.cs @@ -2,7 +2,6 @@ using System.Globalization; using DocoptNet; using Spectre.Console; -using Vezel.Cathode; const string programVersion = "0.1"; @@ -18,7 +17,7 @@ try { - if (!Terminal.StandardIn.IsInteractive) + if (!ctx.Terminal.StandardIn.IsInteractive) throw new CliErrorException(CliExitCode.ErrorUsage, "This app requires an interactive terminal"); { @@ -72,9 +71,9 @@ var (exitCode, options) = StaleCliArguments.CreateParser().Parse( args, programVersion, StaleCliArguments.Help, StaleCliArguments.Usage, - outWriter: Terminal.StandardOut.TextWriter, - errWriter: Terminal.StandardError.TextWriter, - wrapWidth: Terminal.Size.Width); + outWriter: ctx.Terminal.StandardOut.TextWriter, + errWriter: ctx.Terminal.StandardError.TextWriter, + wrapWidth: ctx.Terminal.Size.Width); if (exitCode != null) return (int)exitCode.Value; @@ -93,7 +92,7 @@ { var child = Playback.StartPlayback(ctx, new PlaybackOptions(ctx.Options)); await foreach (var capture in child.Captures.ReadAllAsync(ctx.CancelToken)) - await (capture.IsStdErr ? Terminal.StandardError : Terminal.StandardOut).WriteLineAsync(capture.Line, ctx.CancelToken); + await (capture.IsStdErr ? ctx.Terminal.StandardError : ctx.Terminal.StandardOut).WriteLineAsync(capture.Line, ctx.CancelToken); return await child.Exited; }); } diff --git a/src/Stale.Cli/StatusPane.cs b/src/Stale.Cli/StatusPane.cs index fb97cfa..abb8b1a 100644 --- a/src/Stale.Cli/StatusPane.cs +++ b/src/Stale.Cli/StatusPane.cs @@ -1,50 +1,42 @@ -using System.Diagnostics; +using OkTools.Core.Terminal; using Spectre.Console; +using Vezel.Cathode.Text.Control; class StatusPane { - readonly Screen _screen; readonly string _color; string _text = ""; - int _top; // FUTURE: get rid of 'color' and do a segment config approach like starship etc. - public StatusPane(Screen screen, int top, string color) + public StatusPane(int top, int maxWidth, string color) { - _screen = screen; _color = color; + MaxWidth = maxWidth; Top = top; } - public int Top - { - get => _top; - set - { - Debug.Assert(value >= 0 && value < _screen.ScreenHeight); - _top = value; - } - } + public int Top { get; set; } + public int MaxWidth { get; set; } public void Invalidate() => _text = ""; - public void Print(string text) + public void Print(ControlBuilder cb, string text) { _text = text; - _screen.Control.PrintMarkup(text.Length <= _screen.ScreenWidth - ? $"[{_color}]{text.PadRight(_screen.ScreenWidth).EscapeMarkup()}[/]" - : $"[{_color}]{text[..(_screen.ScreenWidth-Constants.WrapText.Length)].EscapeMarkup()}[/]"+ + cb.PrintMarkup(text.Length <= MaxWidth + ? $"[{_color}]{text.PadRight(MaxWidth).EscapeMarkup()}[/]" + : $"[{_color}]{text[..(MaxWidth-Constants.WrapText.Length)].EscapeMarkup()}[/]"+ $"[{Constants.WrapColor}]{Constants.WrapText}[/]"); } - public void PrintLine(string text) + public void PrintLine(ControlBuilder cb, string text) { - Print(text); - _screen.Control.PrintLine(); + Print(cb, text); + cb.PrintLine(); } - public void Update(string text) + public void Update(ControlBuilder cb, string text) { // TODO: maybe find a span to ffwd to and print limited update (for when simple stuff like coords is changing) // (but don't overengineer..just very basic test to reduce update size) @@ -52,7 +44,7 @@ public void Update(string text) if (_text == text) return; - using var _ = _screen.SaveRestoreCursorPos(0, Top); - Print(text); + using var _ = cb.SaveRestoreCursor(Top, 0); + Print(cb, text); } }