Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e26b27c
Fix progress bar rendering with double-width unicode characters
yotsuda Oct 13, 2025
1309d67
Fix progress bar rendering with double-width Unicode characters
yotsuda Oct 15, 2025
9a571e8
Update src/Microsoft.PowerShell.ConsoleHost/host/msh/ProgressNode.cs
yotsuda Oct 24, 2025
f7d1397
Update test/powershell/Modules/Microsoft.PowerShell.Utility/Write-Pro…
yotsuda Oct 24, 2025
281a5cb
Update src/Microsoft.PowerShell.ConsoleHost/host/msh/ProgressNode.cs
yotsuda Oct 24, 2025
2554a09
Update test/powershell/Modules/Microsoft.PowerShell.Utility/Write-Pro…
yotsuda Oct 25, 2025
0ae07fe
Fix CodeFactor issues: arithmetic precedence and trailing whitespace
yotsuda Oct 25, 2025
a54ca2e
Reapply Copilot suggestions for activityDisplayWidth calculation and …
yotsuda Oct 25, 2025
9627f3f
Fix CodeFactor issue: add blank line after closing brace
yotsuda Oct 25, 2025
21dde97
Use char overload of LengthInBufferCells
yotsuda Oct 26, 2025
463cd77
Add xUnit tests for ProgressNode double-width characters (Issue #21293)
yotsuda Oct 28, 2025
d65f013
Fix CodeFactor warnings: remove unused code and optimize Regex perfor…
yotsuda Oct 28, 2025
35d2336
Use ConsoleControl.LengthInBufferCells directly in tests
yotsuda Oct 28, 2025
96eb58f
Fix CodeFactor issue: remove duplicate assignment in ternary operator
yotsuda Oct 28, 2025
5a77b9a
Refactor: eliminate redundant calculations in RenderAnsi method
yotsuda Oct 28, 2025
2b8c054
Fix CodeFactor style issues in test_ProgressNode.cs
yotsuda Oct 28, 2025
0d65578
Add XML documentation for maxWidth parameter in tests
yotsuda Oct 28, 2025
2c6230a
Add XML documentation for statusText parameter in tests
yotsuda Oct 28, 2025
60f69c1
Reorder classes to place public before internal
yotsuda Oct 28, 2025
e0d89df
Address code review feedback: improve style and optimize tests
yotsuda Nov 2, 2025
82eb674
Merge branch 'master' into fix/progress-bar-unicode
jshigetomi Jun 22, 2026
0e8dd32
Seal TestProgressRawUI test helper and move it to its own file
yotsuda Jun 23, 2026
f206123
Extract RenderAnsiStatusPart / RenderAnsiReverseOff from RenderAnsi
yotsuda Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 101 additions & 37 deletions src/Microsoft.PowerShell.ConsoleHost/host/msh/ProgressNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ namespace Microsoft.PowerShell

this.Style = IsMinimalProgressRenderingEnabled()
? RenderStyle.Ansi
: this.Style = RenderStyle.FullPlus;
: RenderStyle.FullPlus;

this.SourceId = sourceId;
}
Expand Down Expand Up @@ -111,7 +111,7 @@ namespace Microsoft.PowerShell
RenderMinimal(strCollection, indentation, maxWidth, rawUI);
break;
case RenderStyle.Ansi:
RenderAnsi(strCollection, indentation, maxWidth);
RenderAnsi(strCollection, indentation, maxWidth, rawUI);
break;
case RenderStyle.Invisible:
// do nothing
Expand Down Expand Up @@ -368,9 +368,12 @@ internal static bool IsMinimalProgressRenderingEnabled()
/// <param name="maxWidth">
/// The maximum number of chars that the rendering is allowed to consume.
/// </param>
/// <param name="rawUI">
/// The PSHostRawUserInterface used to gauge string widths in the rendering.
/// </param>
private
void
RenderAnsi(ArrayList strCollection, int indentation, int maxWidth)
RenderAnsi(ArrayList strCollection, int indentation, int maxWidth, PSHostRawUserInterface rawUI)
{
string indent = StringUtil.Padding(indentation);
string secRemain = string.Empty;
Expand All @@ -389,68 +392,129 @@ internal static bool IsMinimalProgressRenderingEnabled()

// if the activity is really long, only use up to half the width
string activity;
if (Activity.Length > maxWidth / 2)
int activityDisplayCellsWidth = rawUI.LengthInBufferCells(Activity);
if (activityDisplayCellsWidth > maxWidth / 2)
{
activity = Activity.Substring(0, maxWidth / 2) + PSObjectHelper.Ellipsis;
activity = StringUtil.TruncateToBufferCellWidth(rawUI, Activity, (maxWidth / 2) - 1) + PSObjectHelper.Ellipsis;
}
else
{
activity = Activity;
}

activityDisplayCellsWidth = rawUI.LengthInBufferCells(activity);

// 4 is for the extra space and square brackets below and one extra space
int barWidth = maxWidth - activity.Length - indentation - 4;
int barWidth = maxWidth - activityDisplayCellsWidth - indentation - 4;

var sb = new StringBuilder();
int padding = maxWidth + PSStyle.Instance.Progress.Style.Length + PSStyle.Instance.Reverse.Length + PSStyle.Instance.ReverseOff.Length;
sb.Append(PSStyle.Instance.Reverse);

int maxStatusLength = barWidth - secRemainLength - 1;
if (maxStatusLength > 0 && StatusDescription.Length > barWidth - secRemainLength)
// Build the status description part
int maxStatusWidth = barWidth - secRemainLength;
string statusPart = RenderAnsiStatusPart(rawUI, maxStatusWidth, out int statusPartDisplayWidth);

sb.Append(statusPart);

// Calculate padding needed
int emptyPadLength = barWidth - statusPartDisplayWidth - secRemainLength;
if (emptyPadLength > 0)
{
sb.Append(StatusDescription.AsSpan(0, barWidth - secRemainLength - 1));
sb.Append(PSObjectHelper.Ellipsis);
sb.Append(' ', emptyPadLength);
}
else

sb.Append(secRemain);

// Insert ReverseOff at the correct position for the progress bar
RenderAnsiReverseOff(sb, rawUI, statusPart, barWidth);

strCollection.Add(
StringUtil.Format(
"{0}{1}{2} [{3}]{4}",
indent,
PSStyle.Instance.Progress.Style,
activity,
sb.ToString(),
PSStyle.Instance.Reset));
}

/// <summary>
/// Builds the status-description portion of the Ansi progress bar, truncating it
/// with an ellipsis when it would exceed the available status width.
/// </summary>
/// <param name="rawUI">
/// The PSHostRawUserInterface used to gauge string widths.
/// </param>
/// <param name="maxStatusWidth">
/// The maximum number of buffer cells available for the status description.
/// </param>
/// <param name="statusPartDisplayWidth">
/// On return, the width in buffer cells of the produced status part.
/// </param>
/// <returns>The status description, possibly truncated with an ellipsis.</returns>
private string RenderAnsiStatusPart(PSHostRawUserInterface rawUI, int maxStatusWidth, out int statusPartDisplayWidth)
{
int statusDisplayWidth = rawUI.LengthInBufferCells(StatusDescription);

if (maxStatusWidth <= 0 || statusDisplayWidth <= maxStatusWidth)
{
sb.Append(StatusDescription);
statusPartDisplayWidth = statusDisplayWidth;
return StatusDescription;
}

int emptyPadLength = barWidth + PSStyle.Instance.Reverse.Length - sb.Length - secRemainLength;
if (emptyPadLength > 0)
int ellipsisWidth = rawUI.LengthInBufferCells(PSObjectHelper.EllipsisStr);
string statusPart = StringUtil.TruncateToBufferCellWidth(rawUI, StatusDescription, maxStatusWidth - ellipsisWidth) + PSObjectHelper.EllipsisStr;
statusPartDisplayWidth = rawUI.LengthInBufferCells(statusPart);
return statusPart;
}

/// <summary>
/// Appends the ReverseOff VT sequence to the rendered bar at the buffer-cell position
/// that corresponds to the filled portion of the progress bar, respecting character boundaries.
/// </summary>
/// <param name="sb">The StringBuilder holding the bar contents built so far.</param>
/// <param name="rawUI">
/// The PSHostRawUserInterface used to gauge string widths.
/// </param>
/// <param name="statusPart">The status text at the start of the bar.</param>
/// <param name="barWidth">The total width of the progress bar in buffer cells.</param>
private void RenderAnsiReverseOff(StringBuilder sb, PSHostRawUserInterface rawUI, string statusPart, int barWidth)
{
if (PercentComplete < 0 || PercentComplete >= 100 || barWidth <= 0)
{
sb.Append(string.Empty.PadRight(emptyPadLength));
sb.Append(PSStyle.Instance.ReverseOff);
return;
}

sb.Append(secRemain);
int barLength = PercentComplete * barWidth / 100;
if (barLength >= barWidth)
{
barLength = barWidth - 1;
}

// Calculate the string position where we need to insert ReverseOff.
// We need to find the character position that corresponds to barLength buffer cells.
int stringPos = PSStyle.Instance.Reverse.Length;
int currentCellCount = 0;

if (PercentComplete >= 0 && PercentComplete < 100 && barWidth > 0)
for (int i = 0; i < statusPart.Length && currentCellCount < barLength; i++)
{
int barLength = PercentComplete * barWidth / 100;
if (barLength >= barWidth)
{
barLength = barWidth - 1;
}
currentCellCount += rawUI.LengthInBufferCells(statusPart[i]);
stringPos++;
}

if (barLength < sb.Length)
{
sb.Insert(barLength + PSStyle.Instance.Reverse.Length, PSStyle.Instance.ReverseOff);
}
// Add any padding characters.
int remainingCells = barLength - currentCellCount;
stringPos += Math.Max(0, remainingCells);

if (stringPos < sb.Length)
{
sb.Insert(stringPos, PSStyle.Instance.ReverseOff);
}
else
{
sb.Append(PSStyle.Instance.ReverseOff);
}

strCollection.Add(
StringUtil.Format(
"{0}{1}{2} [{3}]{4}",
indent,
PSStyle.Instance.Progress.Style,
activity,
sb.ToString(),
PSStyle.Instance.Reset)
.PadRight(padding));
}

/// <summary>
Expand Down
56 changes: 56 additions & 0 deletions test/xUnit/csharp/TestProgressRawUI.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Management.Automation.Host;
using Microsoft.PowerShell;

namespace PSTests.Parallel
{
/// <summary>
/// Test helper class that implements PSHostRawUserInterface for progress bar testing.
/// Delegates width calculations to ConsoleControl.LengthInBufferCells.
/// </summary>
internal sealed class TestProgressRawUI : PSHostRawUserInterface
{
public override ConsoleColor ForegroundColor { get; set; }

public override ConsoleColor BackgroundColor { get; set; }

public override Coordinates CursorPosition { get; set; }

public override Coordinates WindowPosition { get; set; }

public override int CursorSize { get; set; }

public override Size BufferSize { get; set; }

public override Size WindowSize { get; set; }

public override Size MaxWindowSize => new Size(120, 50);

public override Size MaxPhysicalWindowSize => new Size(120, 50);

public override string WindowTitle { get; set; }

public override bool KeyAvailable => false;

public override BufferCell[,] GetBufferContents(Rectangle rectangle) => throw new NotImplementedException();

public override void SetBufferContents(Rectangle rectangle, BufferCell fill) => throw new NotImplementedException();

public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) => throw new NotImplementedException();

public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) => throw new NotImplementedException();

public override KeyInfo ReadKey(ReadKeyOptions options) => throw new NotImplementedException();

public override void FlushInputBuffer() => throw new NotImplementedException();

public override int LengthInBufferCells(string str) => ConsoleControl.LengthInBufferCells(str, 0, checkEscapeSequences: true);

public override int LengthInBufferCells(string str, int offset) => ConsoleControl.LengthInBufferCells(str, offset, checkEscapeSequences: true);

public override int LengthInBufferCells(char c) => ConsoleControl.LengthInBufferCells(c);
}
}
Loading
Loading