diff --git a/src/PrettyPrompt/Panes/CodePane.cs b/src/PrettyPrompt/Panes/CodePane.cs index 3ddc5ac..03a276b 100644 --- a/src/PrettyPrompt/Panes/CodePane.cs +++ b/src/PrettyPrompt/Panes/CodePane.cs @@ -163,6 +163,19 @@ internal void Bind(CompletionPane completionPane, OverloadPane overloadPane) this.overloadPane = overloadPane; } + /// + /// Re-capture the submit result's text from the current document, so the returned result matches + /// what's shown (and saved to history) after auto-formatting, not the pre-format snapshot. + /// + internal void RefreshSubmitResultText() + { + if (Result is not { IsSuccess: true }) + { + return; + } + Result = new PromptResult(isSuccess: true, Document.GetText().EnvironmentNewlines(), Result.SubmitKeyInfo); + } + public async Task OnKeyDown(KeyPress key, CancellationToken cancellationToken) { if (key.Handled) return; diff --git a/src/PrettyPrompt/Prompt.cs b/src/PrettyPrompt/Prompt.cs index cc13119..e9e16ad 100644 --- a/src/PrettyPrompt/Prompt.cs +++ b/src/PrettyPrompt/Prompt.cs @@ -111,7 +111,7 @@ private async Task ReadLineCoreAsync() // grab the code area width every key press, so we rerender appropriately when the console is resized. codePane.MeasureConsole(); - await InterpretKeyPress(key, cancellationToken: default).ConfigureAwait(false); + var reformatted = await InterpretKeyPress(key, cancellationToken: default).ConfigureAwait(false); // typing / word-wrapping may have scrolled the console, giving us more room. codePane.MeasureConsole(); @@ -140,7 +140,8 @@ private async Task ReadLineCoreAsync() break; // user submitted the prompt, or a keybinding submitted the prompt case PromptResult or KeyPressCallbackResult: - await RenderSyntaxHighlightedOutput(renderer, codePane, overloadPane, completionPane, key, inputText, result).ConfigureAwait(false); + // a reformat on the submit keystroke must force a repaint (see Renderer.RenderOutput) + await RenderSyntaxHighlightedOutput(renderer, codePane, overloadPane, completionPane, key, inputText, result, forceRedrawOnSubmit: reformatted).ConfigureAwait(false); //wait for potential previous saving await (savePersistentHistoryTask ?? Task.CompletedTask).ConfigureAwait(false); savePersistentHistoryTask = history.SavePersistentHistoryAsync(inputText); @@ -153,7 +154,7 @@ private async Task ReadLineCoreAsync() Debug.Assert(false, "Should never reach here due to infinite " + nameof(KeyPress.ReadForever)); return null; - async Task InterpretKeyPress(KeyPress key, CancellationToken cancellationToken) + async Task InterpretKeyPress(KeyPress key, CancellationToken cancellationToken) { if (!completionPane.WouldKeyPressCommitCompletionItem(key)) { @@ -166,16 +167,19 @@ async Task InterpretKeyPress(KeyPress key, CancellationToken cancellationToken) foreach (var panes in keyPressHandlers) await panes.OnKeyUp(key, cancellationToken).ConfigureAwait(false); - await AutoFormatDocument(key, codePane, cancellationToken).ConfigureAwait(false); + var reformatted = await AutoFormatDocument(key, codePane, cancellationToken).ConfigureAwait(false); //we don't support text selection while completion list is open //text selection can put completion list into broken state, where filtering does not work //so we want this assert to be true Debug.Assert(!completionPane.IsOpen || (codePane.Selection is null)); + + return reformatted; } } - private async Task AutoFormatDocument(KeyPress key, CodePane codePane, CancellationToken cancellationToken) + /// True if the callback reformatted the document (the buffer was changed), else false. + private async Task AutoFormatDocument(KeyPress key, CodePane codePane, CancellationToken cancellationToken) { var text = codePane.Document.GetText(); var (formattedText, newCaret) = await promptCallbacks.FormatInput(text, codePane.Document.Caret, key, cancellationToken).ConfigureAwait(false); @@ -187,17 +191,21 @@ private async Task AutoFormatDocument(KeyPress key, CodePane codePane, Cancellat if (formattedText[i] == '\r') ++removedChars; } codePane.Document.SetContents(codePane, formattedText.Replace("\r\n", "\n"), newCaret - removedChars); + // if this keystroke also submitted, the result text was snapshotted pre-format; re-capture it + codePane.RefreshSubmitResultText(); + return true; } else { Debug.Assert(codePane.Document.Caret == newCaret); + return false; } } - private async Task RenderSyntaxHighlightedOutput(Renderer renderer, CodePane codePane, OverloadPane overloadPane, CompletionPane completionPane, KeyPress key, string inputText, PromptResult? result) + private async Task RenderSyntaxHighlightedOutput(Renderer renderer, CodePane codePane, OverloadPane overloadPane, CompletionPane completionPane, KeyPress key, string inputText, PromptResult? result, bool forceRedrawOnSubmit = false) { var highlights = await highlighter.HighlightAsync(inputText, cancellationToken: default).ConfigureAwait(false); - renderer.RenderOutput(result, codePane, overloadPane, completionPane, highlights, key); + renderer.RenderOutput(result, codePane, overloadPane, completionPane, highlights, key, forceRedrawOnSubmit); } private async Task HandleKeyPressAction(CodePane codePane, KeyPress key, string inputText, CancellationToken cancellationToken) diff --git a/src/PrettyPrompt/Rendering/Renderer.cs b/src/PrettyPrompt/Rendering/Renderer.cs index 04f442f..b802e5b 100644 --- a/src/PrettyPrompt/Rendering/Renderer.cs +++ b/src/PrettyPrompt/Rendering/Renderer.cs @@ -56,11 +56,13 @@ public void RenderOutput( OverloadPane overloadPane, CompletionPane completionPane, IReadOnlyCollection highlights, - KeyPress key) + KeyPress key, + bool forceRedraw = false) { if (result is not null) { - bool redraw = false; + // reformat-on-submit leaves the on-screen line stale, so repaint it. https://github.com/waf/CSharpRepl/issues/356 + bool redraw = forceRedraw; if (wasTextSelectedDuringPreviousRender && codePane.Selection is null) { redraw = true; diff --git a/tests/PrettyPrompt.Tests/PromptTests.cs b/tests/PrettyPrompt.Tests/PromptTests.cs index 081d713..52f3da5 100644 --- a/tests/PrettyPrompt.Tests/PromptTests.cs +++ b/tests/PrettyPrompt.Tests/PromptTests.cs @@ -111,6 +111,45 @@ public async Task ReadLine_WordWrap() ); } + /// + /// Regression test for https://github.com/waf/CSharpRepl/issues/356. + /// When reformats the buffer on the very keystroke that + /// submits the prompt, the submit render path must force a redraw so the committed line shows the + /// formatted text. Otherwise the on-screen line is left showing the stale pre-format text, because + /// the reformat happened after the previous render and the submit path skips redrawing by default. + /// + [Fact] + public async Task ReadLine_FormatInputReformatsOnSubmit_RedrawsFormattedText() + { + var console = ConsoleStub.NewConsole(); + console.StubInput($"hello{Enter}"); + + var prompt = new Prompt( + callbacks: new TestPromptCallbacks + { + // Only reformat on the submit keystroke (Enter), so the formatted text has never been + // rendered before submit - that's the scenario the fix addresses. + FormatInputCallback = (text, caret, keyPress) => + Task.FromResult( + keyPress.ConsoleKeyInfo.Key == Enter + ? (text.ToUpperInvariant(), caret) + : (text, caret)) + }, + console: console); + + var result = await prompt.ReadLineAsync(); + + Assert.True(result.IsSuccess); + + // The returned result reflects the formatted text (re-captured after AutoFormatDocument), not the + // pre-format snapshot taken when the submit key was first handled. + Assert.Equal("HELLO", result.Text); + + // ...and the final render repaints it; without the forced redraw the screen would still show the + // lowercase "hello" typed before the submit keystroke. + Assert.Contains("HELLO", console.GetFinalOutput()); + } + [Fact] public async Task ReadLine_HorizontalNavigationKeys() { diff --git a/tests/PrettyPrompt.Tests/TestPromptCallbacks.cs b/tests/PrettyPrompt.Tests/TestPromptCallbacks.cs index 837fb94..25f0197 100644 --- a/tests/PrettyPrompt.Tests/TestPromptCallbacks.cs +++ b/tests/PrettyPrompt.Tests/TestPromptCallbacks.cs @@ -16,6 +16,7 @@ namespace PrettyPrompt.Tests; internal delegate Task> HighlightCallbackAsync(string text); internal delegate Task TransformKeyPressAsyncCallbackAsync(string text, int caret, KeyPress keyPress); internal delegate Task<(IReadOnlyList, int ArgumentIndex)> GetOverloadsCallbackAsync(string text, int caret); +internal delegate Task<(string Text, int Caret)> FormatInputCallbackAsync(string text, int caret, KeyPress keyPress); internal class TestPromptCallbacks : PromptCallbacks { @@ -29,6 +30,7 @@ internal class TestPromptCallbacks : PromptCallbacks public HighlightCallbackAsync? HighlightCallback { get; set; } public TransformKeyPressAsyncCallbackAsync? TransformKeyPressCallback { get; set; } public GetOverloadsCallbackAsync? GetOverloadsCallback { get; set; } + public FormatInputCallbackAsync? FormatInputCallback { get; set; } public TestPromptCallbacks(params (KeyPressPattern Pattern, KeyPressCallbackAsync Callback)[]? keyPressCallbacks) { @@ -100,4 +102,12 @@ GetOverloadsCallback is null ? base.GetOverloadsAsync(text, caret, cancellationToken) : GetOverloadsCallback(text, caret); } + + protected override Task<(string Text, int Caret)> FormatInput(string text, int caret, KeyPress keyPress, CancellationToken cancellationToken) + { + return + FormatInputCallback is null ? + base.FormatInput(text, caret, keyPress, cancellationToken) : + FormatInputCallback(text, caret, keyPress); + } } \ No newline at end of file