diff --git a/src/Build.UnitTests/TerminalLogger_Tests.cs b/src/Build.UnitTests/TerminalLogger_Tests.cs index 85323cfea2a..a2cb5c78373 100644 --- a/src/Build.UnitTests/TerminalLogger_Tests.cs +++ b/src/Build.UnitTests/TerminalLogger_Tests.cs @@ -12,6 +12,7 @@ using Microsoft.Build.BackEnd.Logging; using Microsoft.Build.CommandLine.UnitTests; using Microsoft.Build.Framework; +using Microsoft.Build.Framework.Logging; using Microsoft.Build.Logging; using Microsoft.Build.UnitTests.Shared; using Shouldly; @@ -134,6 +135,27 @@ void IBuildEventSink.ShutDown() [UseInvariantCulture] public class TerminalLogger_Tests { + private sealed class ResizableTerminal(TextWriter output, int width, int height) : ITerminal + { + private readonly Terminal _terminal = new(output); + + public int Width { get; set; } = width; + public int Height { get; } = height; + public bool SupportsProgressReporting => false; + + public void BeginUpdate() => _terminal.BeginUpdate(); + public void EndUpdate() => _terminal.EndUpdate(); + public void Write(string text) => _terminal.Write(text); + public void Write(ReadOnlySpan text) => _terminal.Write(text); + public void WriteLine(string text) => _terminal.WriteLine(text); + public void WriteLineFitToWidth(ReadOnlySpan text) => _terminal.WriteLineFitToWidth(text); + public void WriteColor(TerminalColor color, string text) => _terminal.WriteColor(color, text); + public void WriteColorLine(TerminalColor color, string text) => _terminal.WriteColorLine(color, text); + public void Dispose() + { + } + } + private const int _nodeCount = 8; private const string _immediateMessageString = @@ -895,6 +917,47 @@ public void DisplayNodesOverwritesTime() } } + [Fact] + public void RefreshRedrawsAfterTerminalResizeSettles() + { + using StringWriter output = new(); + using ResizableTerminal terminal = new(output, width: 120, height: 40); + + MockBuildEventSink eventSource = new(0); + TerminalLogger terminalLogger = new(terminal); + try + { + terminalLogger.Initialize(eventSource, _nodeCount); + eventSource.InvokeBuildStarted(MakeBuildStartedEventArgs()); + eventSource.InvokeStatusEventRaised(MakeProjectEvalFinishedArgs(_projectFile)); + eventSource.InvokeProjectStarted(MakeProjectStartedEventArgs(_projectFile)); + eventSource.InvokeTargetStarted(MakeTargetStartedEventArgs(_projectFile, "Build")); + eventSource.InvokeTaskStarted(MakeTaskStartedEventArgs(_projectFile, "Task")); + + terminalLogger.Refresh(); + output.GetStringBuilder().Clear(); + + foreach (int width in new[] { 100, 80, 60, 40 }) + { + terminal.Width = width; + terminalLogger.Refresh(); + } + + output.ToString().ShouldBeEmpty(); + + terminalLogger.Refresh(); + terminalLogger.Refresh(); + terminalLogger.Refresh(); + + output.ToString().ShouldStartWith($"{AnsiCodes.CSI}4{AnsiCodes.MoveUpToLineStart}"); + output.ToString().ShouldContain($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); + } + finally + { + terminalLogger.Shutdown(); + } + } + [Fact] public async Task DisplayNodesOverwritesWithNewTargetFramework() diff --git a/src/Build/Logging/TerminalLogger/TerminalLogger.cs b/src/Build/Logging/TerminalLogger/TerminalLogger.cs index 267ee49e486..ca5e4db6f7b 100644 --- a/src/Build/Logging/TerminalLogger/TerminalLogger.cs +++ b/src/Build/Logging/TerminalLogger/TerminalLogger.cs @@ -96,6 +96,12 @@ public EvalContext(BuildEventContext context) /// private readonly CancellationTokenSource _cts = new(); + private const int TerminalSizeSettlingFrames = 3; + + private int _lastTerminalWidth; + private int _lastTerminalHeight; + private int _terminalSizeStableFrames = TerminalSizeSettlingFrames; + /// /// Tracks the status of all relevant projects seen so far. /// @@ -1472,42 +1478,64 @@ private void ErrorRaised(object sender, BuildErrorEventArgs e) private void ThreadProc() { // 1_000 / 30 is a poor approx of 30Hz - int count = 0; while (!_cts.Token.WaitHandle.WaitOne(1_000 / 30)) { - count++; - lock (_lock) + Refresh(); + } + + EraseNodes(); + } + + /// + /// Refreshes the node display using the current terminal dimensions. + /// + internal void Refresh() + { + int width = Terminal.Width; + int height = Terminal.Height; + + lock (_lock) + { + if (_currentFrame.NodesCount > 0) { - // Querying the terminal for it's dimensions is expensive, so we only do it every 30 frames e.g. once a second. - if (count >= 30) + if (width != _lastTerminalWidth || height != _lastTerminalHeight) { - count = 0; - DisplayNodes(); + _lastTerminalWidth = width; + _lastTerminalHeight = height; + _terminalSizeStableFrames = 0; + return; } - else + + if (_terminalSizeStableFrames < TerminalSizeSettlingFrames) { - DisplayNodes(false); + _terminalSizeStableFrames++; + if (_terminalSizeStableFrames < TerminalSizeSettlingFrames) + { + return; + } } } - } - EraseNodes(); + _lastTerminalWidth = width; + _lastTerminalHeight = height; + DisplayNodes(width, height); + } } /// /// Render Nodes section. /// It shows what all build nodes do. /// - internal void DisplayNodes(bool updateSize = true) + internal void DisplayNodes() => DisplayNodes(Terminal.Width, Terminal.Height); + + private void DisplayNodes(int width, int height) { - int width = updateSize ? Terminal.Width : _currentFrame.Width; - int height = updateSize ? Terminal.Height : _currentFrame.Height; TerminalNodesFrame newFrame = new TerminalNodesFrame(_nodes, width: width, height: height); // Do not render delta but clear everything if Terminal width or height have changed. if (newFrame.Width != _currentFrame.Width || newFrame.Height != _currentFrame.Height) { - EraseNodes(); + EraseNodes(width); } string rendered = newFrame.Render(_currentFrame); @@ -1529,13 +1557,15 @@ internal void DisplayNodes(bool updateSize = true) /// /// Erases the previously printed live node output. /// - private void EraseNodes() + private void EraseNodes() => EraseNodes(Terminal.Width); + + private void EraseNodes(int terminalWidth) { if (_currentFrame.NodesCount == 0) { return; } - Terminal.WriteLine($"{AnsiCodes.CSI}{_currentFrame.NodesCount + 1}{AnsiCodes.MoveUpToLineStart}"); + Terminal.WriteLine($"{AnsiCodes.CSI}{_currentFrame.GetPhysicalRows(terminalWidth) + 1}{AnsiCodes.MoveUpToLineStart}"); Terminal.Write($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); _currentFrame.Clear(); } diff --git a/src/Build/Logging/TerminalLogger/TerminalNodesFrame.cs b/src/Build/Logging/TerminalLogger/TerminalNodesFrame.cs index 74232e078d2..95beebe137c 100644 --- a/src/Build/Logging/TerminalLogger/TerminalNodesFrame.cs +++ b/src/Build/Logging/TerminalLogger/TerminalNodesFrame.cs @@ -16,7 +16,7 @@ internal sealed class TerminalNodesFrame { private const int MaxColumn = 120; - private readonly (TerminalNodeStatus nodeStatus, int durationLength)[] _nodes; + private readonly (TerminalNodeStatus nodeStatus, int durationLength, int renderedWidth)[] _nodes; private readonly StringBuilder _renderBuilder = new(); @@ -29,7 +29,7 @@ public TerminalNodesFrame(TerminalNodeStatus?[] nodes, int width, int height) Width = Math.Min(width, MaxColumn); Height = height; - _nodes = new (TerminalNodeStatus, int)[nodes.Length]; + _nodes = new (TerminalNodeStatus, int, int)[nodes.Length]; foreach (TerminalNodeStatus? status in nodes) { @@ -78,11 +78,13 @@ internal ReadOnlySpan RenderNodeStatus(int i) if (renderedWidth > Width) { + _nodes[i].renderedWidth = project.Length; return project.AsSpan(); } } } + _nodes[i].renderedWidth = Math.Max(Width - 1, 1); var renderedTarget = !string.IsNullOrWhiteSpace(targetPrefix) ? $"{AnsiCodes.Colorize(targetPrefix, targetPrefixColor)} {target}" : target; var builder = StringBuilderCache.Acquire(renderedWidth); builder.Append(TerminalLogger.Indentation).Append(project); @@ -121,7 +123,7 @@ public string Render(TerminalNodesFrame previousFrame) sb.Clear(); // Move cursor back to 1st line of nodes. - sb.AppendLine($"{AnsiCodes.CSI}{previousFrame.NodesCount + 1}{AnsiCodes.MoveUpToLineStart}"); + sb.AppendLine($"{AnsiCodes.CSI}{previousFrame.GetPhysicalRows(Width) + 1}{AnsiCodes.MoveUpToLineStart}"); int i = 0; for (; i < NodesCount; i++) @@ -163,6 +165,20 @@ public string Render(TerminalNodesFrame previousFrame) return sb.ToString(); } + internal int GetPhysicalRows(int terminalWidth) + { + terminalWidth = Math.Max(terminalWidth, 1); + + int physicalRows = 0; + for (int i = 0; i < NodesCount; i++) + { + int renderedWidth = Math.Max(_nodes[i].renderedWidth, 1); + physicalRows += ((renderedWidth - 1) / terminalWidth) + 1; + } + + return physicalRows; + } + public void Clear() { NodesCount = 0;