// Copyright (c) Microsoft. All rights reserved. using Harness.ConsoleReactiveFramework; namespace Harness.ConsoleReactiveComponents; /// /// Props for . /// public record TextScrollPanelProps : ConsoleReactiveProps { /// Gets the items to render in the scroll panel. Each item is a pre-rendered /// console string (may include ANSI escape sequences and newlines). public IReadOnlyList Items { get; init; } = []; } /// /// State for . /// public record TextScrollPanelState : ConsoleReactiveState; /// /// 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 to force a full re-render. /// public class TextScrollPanel : ConsoleReactiveComponent { private int _renderedCount; private int _lastItemOffsetFromBottom; /// /// Initializes a new instance of the class. /// public TextScrollPanel() { this.State = new TextScrollPanelState(); } /// public override void Invalidate() { this._renderedCount = 0; this._lastItemOffsetFromBottom = 0; base.Invalidate(); } /// 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; } }