chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Provides descriptive helpers for common ANSI/VT100 escape sequences used
|
||||
/// in the split-console layout (DECSTBM scroll regions, cursor movement, line erasure).
|
||||
/// </summary>
|
||||
public static class AnsiEscapes
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the scrollable region to rows 1 through <paramref name="bottom"/> (DECSTBM).
|
||||
/// Content outside this region will not scroll.
|
||||
/// </summary>
|
||||
public static string SetScrollRegion(int bottom) => $"\x1b[1;{bottom}r";
|
||||
|
||||
/// <summary>
|
||||
/// Resets the scroll region to the full terminal height (DECSTBM reset).
|
||||
/// </summary>
|
||||
public static string ResetScrollRegion => "\x1b[r";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> and <paramref name="column"/> (CUP).
|
||||
/// </summary>
|
||||
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the current line from the cursor position to the end of the line (EL 0).
|
||||
/// </summary>
|
||||
public static string EraseToEndOfLine => "\x1b[0K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire current line (EL 2).
|
||||
/// </summary>
|
||||
public static string EraseEntireLine => "\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire screen.
|
||||
/// </summary>
|
||||
public static string EraseEntireScreen => "\x1b[2J";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the scrollback buffer (ESC[3J). Use alongside <see cref="EraseEntireScreen"/>
|
||||
/// to fully clear both the visible screen and the scroll history.
|
||||
/// </summary>
|
||||
public static string EraseScrollbackBuffer => "\x1b[3J";
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current cursor position (DECSC / SCP).
|
||||
/// Note: most terminals have a single save slot — nested saves are not supported.
|
||||
/// </summary>
|
||||
public static string SaveCursor => "\x1b[s";
|
||||
|
||||
/// <summary>
|
||||
/// Restores the previously saved cursor position (DECRC / RCP).
|
||||
/// </summary>
|
||||
public static string RestoreCursor => "\x1b[u";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> at column 1, then erases the entire line.
|
||||
/// Convenience combination of <see cref="MoveCursor"/> and <see cref="EraseEntireLine"/>.
|
||||
/// </summary>
|
||||
public static string MoveAndEraseLine(int row) => $"\x1b[{row};1H\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the foreground text color using a <see cref="ConsoleColor"/> value.
|
||||
/// </summary>
|
||||
public static string SetForegroundColor(ConsoleColor color) => $"\x1b[{ConsoleColorToAnsi(color)}m";
|
||||
|
||||
/// <summary>
|
||||
/// Resets all text attributes (color, bold, etc.) to their defaults.
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
|
||||
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
|
||||
/// combining characters, variation selectors, and East Asian wide characters may be
|
||||
/// measured incorrectly. For the console harness this is acceptable since content is
|
||||
/// predominantly ASCII, and emoji are padded with surrounding spaces.
|
||||
/// </remarks>
|
||||
public static int VisibleLength(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int length = 0;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
|
||||
{
|
||||
// Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E).
|
||||
i += 2;
|
||||
while (i < text.Length && text[i] < 0x40)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
|
||||
}
|
||||
else if (text[i] != '\n' && text[i] != '\r')
|
||||
{
|
||||
length++;
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
public static int CountPhysicalLines(string text, int terminalWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int physicalLines = 0;
|
||||
int lineStart = 0;
|
||||
|
||||
for (int i = 0; i <= text.Length; i++)
|
||||
{
|
||||
if (i == text.Length || text[i] == '\n')
|
||||
{
|
||||
if (terminalWidth <= 0)
|
||||
{
|
||||
// No wrapping — each logical line is one physical row
|
||||
physicalLines += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicalLine = text[lineStart..i];
|
||||
int visibleWidth = VisibleLength(logicalLine);
|
||||
|
||||
physicalLines += visibleWidth == 0
|
||||
? 1
|
||||
: (visibleWidth - 1) / terminalWidth + 1;
|
||||
}
|
||||
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
physicalLines--;
|
||||
}
|
||||
|
||||
return physicalLines;
|
||||
}
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
ConsoleColor.DarkRed => 31,
|
||||
ConsoleColor.DarkGreen => 32,
|
||||
ConsoleColor.DarkYellow => 33,
|
||||
ConsoleColor.DarkBlue => 34,
|
||||
ConsoleColor.DarkMagenta => 35,
|
||||
ConsoleColor.DarkCyan => 36,
|
||||
ConsoleColor.Gray => 37,
|
||||
ConsoleColor.DarkGray => 90,
|
||||
ConsoleColor.Red => 91,
|
||||
ConsoleColor.Green => 92,
|
||||
ConsoleColor.Yellow => 93,
|
||||
ConsoleColor.Blue => 94,
|
||||
ConsoleColor.Magenta => 95,
|
||||
ConsoleColor.Cyan => 96,
|
||||
ConsoleColor.White => 97,
|
||||
_ => 37
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a selectable list of items with a cursor indicator.
|
||||
/// The selected item is indicated with a ">" prefix and rendered in the highlight color.
|
||||
/// Optionally includes a title above the list and a custom text input option at the bottom.
|
||||
/// </summary>
|
||||
public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the list,
|
||||
/// including the optional title and custom text input row.
|
||||
/// </summary>
|
||||
/// <param name="props">The list selection props.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(ListSelectionProps props)
|
||||
{
|
||||
int height = props.Items.Count;
|
||||
if (props.CustomTextPlaceholder != null)
|
||||
{
|
||||
height++;
|
||||
}
|
||||
|
||||
height += GetTitleLineCount(props.Title);
|
||||
return height;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(ListSelectionProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int row = 0;
|
||||
|
||||
// Render the title lines (if any)
|
||||
if (props.Title is not null)
|
||||
{
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(line);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the list items + optional custom text row
|
||||
int totalItems = props.Items.Count + (props.CustomTextPlaceholder != null ? 1 : 0);
|
||||
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
|
||||
// Cursor indicator
|
||||
Console.Write(isSelected ? "> " : " ");
|
||||
|
||||
if (isCustomTextOption)
|
||||
{
|
||||
this.RenderCustomTextOption(props, isSelected);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of lines the title occupies, or 0 if no title is set.
|
||||
/// </summary>
|
||||
private static int GetTitleLineCount(string? title) =>
|
||||
title is null ? 0 : title.Split('\n').Length;
|
||||
|
||||
private void RenderCustomTextOption(ListSelectionProps props, bool isSelected)
|
||||
{
|
||||
if (props.CustomText.Length > 0)
|
||||
{
|
||||
// User has typed text — render in highlight color if selected
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(props.CustomTextPlaceholder))
|
||||
{
|
||||
// No text — show placeholder in dark grey (or highlight color if selected)
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="ListSelection"/>.
|
||||
/// </summary>
|
||||
public record ListSelectionProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the title text displayed above the list items. May contain newlines for multi-line titles.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Gets the items to display in the list.</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Gets the zero-based index of the currently selected item.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the highlight color for the active item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||
public ConsoleColor HighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option. If <c>null</c>, no custom option is shown.</summary>
|
||||
public string? CustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the text being typed into the custom text input option.</summary>
|
||||
public string CustomText { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextInput"/>.
|
||||
/// </summary>
|
||||
public record TextInputProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the prompt string displayed on the left (e.g. "> " or "user > ").</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the text content to render to the right of the prompt.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the placeholder text shown in dark grey when <see cref="Text"/> is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a prompt with text input. Supports multi-line text
|
||||
/// where continuation lines are indented to align with the text start position
|
||||
/// (i.e. the column after the prompt).
|
||||
/// </summary>
|
||||
public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the prompt and text
|
||||
/// given the available width.
|
||||
/// </summary>
|
||||
/// <param name="props">The text input props.</param>
|
||||
/// <param name="availableWidth">The total available width in columns.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(TextInputProps props, int availableWidth)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = availableWidth - promptLength;
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
int lines = 1;
|
||||
int remaining = props.Text.Length - textWidth;
|
||||
while (remaining > 0)
|
||||
{
|
||||
lines++;
|
||||
remaining -= textWidth;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = props.Width - promptLength;
|
||||
string indent = new(' ', promptLength);
|
||||
|
||||
// First line: prompt + start of text
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(props.Prompt);
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
// Show placeholder if text is empty
|
||||
if (props.Text.Length == 0 && props.Placeholder.Length > 0 && textWidth > 0)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
Console.Write(" ");
|
||||
Console.Write(props.Placeholder[..Math.Min(props.Placeholder.Length, textWidth - 1)]);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
int firstChunk = Math.Min(textWidth, props.Text.Length);
|
||||
Console.Write(props.Text[offset..firstChunk]);
|
||||
offset = firstChunk;
|
||||
|
||||
// Continuation lines: indented to align with text start
|
||||
int row = 1;
|
||||
while (offset < props.Text.Length)
|
||||
{
|
||||
int chunk = Math.Min(textWidth, props.Text.Length - offset);
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(indent);
|
||||
Console.Write(props.Text[offset..(offset + chunk)]);
|
||||
offset += chunk;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
public record TextPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a list of pre-rendered string items vertically.
|
||||
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
|
||||
/// exceeds the number of output lines, leftover lines are erased.
|
||||
/// </summary>
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items,
|
||||
/// accounting for terminal line wrapping at the specified width.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextPanelProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int currentRow = 0;
|
||||
|
||||
for (int i = 0; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
|
||||
{
|
||||
int linePhysicalRows = props.Width > 0
|
||||
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
|
||||
: 1;
|
||||
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
|
||||
currentRow += linePhysicalRows;
|
||||
itemRow += linePhysicalRows;
|
||||
}
|
||||
}
|
||||
|
||||
// If the component height exceeds the output, erase leftover lines
|
||||
if (props.Height > currentRow)
|
||||
{
|
||||
for (int i = currentRow; i < props.Height; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelState : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// The last rendered item is considered dynamic and will be re-rendered on each call.
|
||||
/// All prior items are considered finalized and are not re-rendered.
|
||||
/// Use <see cref="Invalidate"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private int _renderedCount;
|
||||
private int _lastItemOffsetFromBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
public TextScrollPanel()
|
||||
{
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Invalidate()
|
||||
{
|
||||
this._renderedCount = 0;
|
||||
this._lastItemOffsetFromBottom = 0;
|
||||
base.Invalidate();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState state)
|
||||
{
|
||||
if (props.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int bottomRow = props.Y + props.Height - 1;
|
||||
|
||||
// Determine the first item to render. If we previously rendered items,
|
||||
// re-render the last one (it may have changed/grown) from its stored position.
|
||||
int startIndex = this._renderedCount > 0 ? this._renderedCount - 1 : 0;
|
||||
|
||||
if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0)
|
||||
{
|
||||
// Reposition cursor to where the last rendered item began
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X));
|
||||
}
|
||||
else
|
||||
{
|
||||
// First render — position at the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X));
|
||||
}
|
||||
|
||||
// Render from startIndex onwards
|
||||
for (int i = startIndex; i < props.Items.Count; i++)
|
||||
{
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item,
|
||||
// accounting for terminal line wrapping at the available width.
|
||||
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TopBottomRule"/>.
|
||||
/// </summary>
|
||||
public record TopBottomRuleProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
|
||||
public ConsoleColor? Color { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a top and bottom horizontal rule (─) with children
|
||||
/// stacked vertically between them.
|
||||
/// </summary>
|
||||
public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the total height including the top rule, children, and bottom rule.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props containing children.</param>
|
||||
/// <returns>2 (for the rules) plus the sum of all children heights.</returns>
|
||||
public static int CalculateHeight(TopBottomRuleProps props)
|
||||
{
|
||||
int childrenHeight = 0;
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
childrenHeight += child.BaseProps?.Height ?? 0;
|
||||
}
|
||||
|
||||
// Top rule + children + bottom rule
|
||||
return 2 + childrenHeight;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int ruleWidth = props.Width;
|
||||
string rule = new('─', ruleWidth);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Top rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(rule);
|
||||
|
||||
// Render children stacked below the top rule
|
||||
int currentY = props.Y + 1;
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
|
||||
child.Render();
|
||||
currentY += child.BaseProps.Height;
|
||||
}
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Bottom rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
|
||||
Console.Write(rule);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user