diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs index 8a0c8b982e..eedc76a145 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs @@ -39,7 +39,7 @@ public override void RenderCore(ListSelectionProps props, ConsoleReactiveState s { foreach (string line in props.Title.Split('\n')) { - Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X)); Console.Write(AnsiEscapes.EraseEntireLine); Console.Write(line); row++; @@ -51,7 +51,7 @@ public override void RenderCore(ListSelectionProps props, ConsoleReactiveState s for (int i = 0; i < totalItems; i++) { - Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X)); Console.Write(AnsiEscapes.EraseEntireLine); bool isSelected = i == props.SelectedIndex; diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextInput.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextInput.cs index 7c6e9aea17..a13d0e7d07 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextInput.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextInput.cs @@ -58,11 +58,11 @@ public static int CalculateHeight(TextInputProps props, int availableWidth) public override void RenderCore(TextInputProps props, ConsoleReactiveState state) { int promptLength = props.Prompt.Length; - int textWidth = this.Width - promptLength; + int textWidth = props.Width - promptLength; string indent = new(' ', promptLength); // First line: prompt + start of text - Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X)); Console.Write(AnsiEscapes.EraseEntireLine); Console.Write(props.Prompt); @@ -90,7 +90,7 @@ public override void RenderCore(TextInputProps props, ConsoleReactiveState state while (offset < props.Text.Length) { int chunk = Math.Min(textWidth, props.Text.Length - offset); - Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X)); Console.Write(AnsiEscapes.EraseEntireLine); Console.Write(indent); Console.Write(props.Text[offset..(offset + chunk)]); diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index a9651f7726..5692b58266 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -17,7 +17,7 @@ public record TextPanelProps : ConsoleReactiveProps /// /// 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 +/// re-rendered on each update. If the component's /// exceeds the number of output lines, leftover lines are erased. /// public class TextPanel : ConsoleReactiveComponent @@ -51,18 +51,18 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state for (int j = 0; j < lineCount; j++) { - Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow)); + Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow)); Console.Write(lines[j]); currentRow++; } } // If the component height exceeds the output, erase leftover lines - if (this.Height > currentRow) + if (props.Height > currentRow) { - for (int i = currentRow; i < this.Height; i++) + for (int i = currentRow; i < props.Height; i++) { - Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i)); + Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i)); } } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs index f0b156cd5a..15147b0fd0 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs @@ -52,7 +52,7 @@ public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState } // Move cursor to the bottom of the scroll area - Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X)); // Output only new items since last rendered for (int i = state.RenderedCount; i < props.Items.Count; i++) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TopBottomRule.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TopBottomRule.cs index 0cac2a1b8a..a08801bbd2 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TopBottomRule.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TopBottomRule.cs @@ -9,9 +9,6 @@ namespace Harness.ConsoleReactiveComponents; /// public record TopBottomRuleProps : ConsoleReactiveProps { - /// Gets the width of the horizontal rules in characters. - public int Width { get; init; } - /// Gets the foreground color of the horizontal rules. If null, the default terminal color is used. public ConsoleColor? Color { get; init; } } @@ -32,7 +29,7 @@ public static int CalculateHeight(TopBottomRuleProps props) int childrenHeight = 0; foreach (var child in props.Children) { - childrenHeight += child.Height; + childrenHeight += child.BaseProps?.Height ?? 0; } // Top rule + children + bottom rule @@ -51,11 +48,11 @@ public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState s } // Top rule - Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X)); + Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X)); Console.Write(rule); // Render children stacked below the top rule - int currentY = this.Y + 1; + int currentY = props.Y + 1; if (props.Color.HasValue) { @@ -64,10 +61,9 @@ public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState s foreach (var child in props.Children) { - child.X = this.X; - child.Y = currentY; + child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY }; child.Render(); - currentY += child.Height; + currentY += child.BaseProps.Height; } if (props.Color.HasValue) @@ -76,7 +72,7 @@ public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState s } // Bottom rule - Console.Write(AnsiEscapes.MoveCursor(currentY, this.X)); + Console.Write(AnsiEscapes.MoveCursor(currentY, props.X)); Console.Write(rule); if (props.Color.HasValue) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs index d1dfc34bcf..d71436e5e6 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs @@ -3,8 +3,8 @@ namespace Harness.ConsoleReactiveFramework; /// -/// Abstract base class for all console UI components. Provides layout properties -/// (position and size) and a method for drawing to the console. +/// Abstract base class for all console UI components. Provides access to layout +/// through and a method for drawing to the console. /// Derive from instead of this class directly. /// public abstract class ConsoleReactiveComponent @@ -13,20 +13,21 @@ internal ConsoleReactiveComponent() { } - /// Gets or sets the 1-based column position of the component. - public int X { get; set; } - - /// Gets or sets the 1-based row position of the component. - public int Y { get; set; } - - /// Gets or sets the width of the component in columns. - public int Width { get; set; } - - /// Gets or sets the height of the component in rows. - public int Height { get; set; } + /// + /// Gets or sets the component's props as the base type. + /// Used by parent components to set layout (X, Y, Width, Height) on children without + /// knowing the concrete props type. + /// + public abstract ConsoleReactiveProps? BaseProps { get; set; } /// Renders the component to the console at its current position. public abstract void Render(); + + /// + /// Invalidates the component's cached render state, causing the next call + /// to proceed even if props and state have not changed. Use after a screen erase to force repaint. + /// + public abstract void Invalidate(); } /// @@ -46,6 +47,13 @@ public abstract class ConsoleReactiveComponent : ConsoleReactive /// Gets or sets the component's props (external configuration). public TProps? Props { get; set; } + /// + public override ConsoleReactiveProps? BaseProps + { + get => this.Props; + set => this.Props = (TProps?)value; + } + /// Gets or sets the component's internal state. protected TState? State { get; set; } @@ -73,8 +81,8 @@ public override void Render() return; } - if (ReferenceEquals(this.Props, this._lastRenderedProps) - && ReferenceEquals(this.State, this._lastRenderedState)) + if (EqualityComparer.Default.Equals(this.Props, this._lastRenderedProps) + && EqualityComparer.Default.Equals(this.State, this._lastRenderedState)) { return; } @@ -86,6 +94,16 @@ public override void Render() } } + /// + public override void Invalidate() + { + lock (this._renderLock) + { + this._lastRenderedProps = default; + this._lastRenderedState = default; + } + } + /// /// Called by to perform the actual rendering. Override this in derived classes. /// @@ -95,11 +113,23 @@ public override void Render() } /// -/// Base record for component props. Provides an optional collection -/// for composing child components. +/// Base record for component props. Provides layout properties (position and size) +/// and an optional collection for composing child components. /// public record ConsoleReactiveProps { + /// Gets the 1-based column position of the component. + public int X { get; init; } + + /// Gets the 1-based row position of the component. + public int Y { get; init; } + + /// Gets the width of the component in columns. + public int Width { get; init; } + + /// Gets the height of the component in rows. + public int Height { get; init; } + /// Gets the child components to render within this component. public IReadOnlyList Children { get; init; } = []; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentModeAndHelp.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentModeAndHelp.cs index 97579992fd..2e1d86a413 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentModeAndHelp.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentModeAndHelp.cs @@ -43,7 +43,7 @@ public override void RenderCore(AgentModeAndHelpProps props, ConsoleReactiveStat } System.Console.Write(AnsiEscapes.SaveCursor); - System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y)); + System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y)); bool hasMode = props.Mode is not null; diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentStatus.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentStatus.cs index 725e1ffaa6..ba74b21f7a 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentStatus.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Components/AgentStatus.cs @@ -86,7 +86,7 @@ public override void RenderCore(AgentStatusProps props, AgentStatusState state) } System.Console.Write(AnsiEscapes.SaveCursor); - System.Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X)); + System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X)); if (props != this._previousProps) { System.Console.Write(AnsiEscapes.EraseToEndOfLine); diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs index 44f849b254..9521715467 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -371,7 +371,7 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS }; bottomChildHeight = ListSelection.CalculateHeight(listProps); - this._listSelection.Height = bottomChildHeight; + listProps = listProps with { Height = bottomChildHeight }; this._listSelection.Props = listProps; bottomChild = this._listSelection; } @@ -398,8 +398,7 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS } bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); - this._textInput.Width = state.ConsoleWidth; - this._textInput.Height = bottomChildHeight; + textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight }; this._textInput.Props = textInputProps; bottomChild = this._textInput; } @@ -413,8 +412,7 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS }; bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); - this._textInput.Width = state.ConsoleWidth; - this._textInput.Height = bottomChildHeight; + textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight }; this._textInput.Props = textInputProps; bottomChild = this._textInput; } @@ -459,6 +457,16 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS System.Console.Write(AnsiEscapes.EraseScrollbackBuffer); this._textScrollPanel.Reset(); this._resizedSinceLastRender = false; + + // Invalidate all children so they re-render even if props haven't changed + this._rule.Invalidate(); + this._textScrollPanel.Invalidate(); + this._textPanel.Invalidate(); + this._queuedPanel.Invalidate(); + this._agentStatus.Invalidate(); + this._modeAndHelp.Invalidate(); + this._textInput.Invalidate(); + this._listSelection.Invalidate(); } this._scrollRegionBottom = scrollBottom; @@ -470,35 +478,35 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS ? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList() : []; - this._textScrollPanel.X = 1; - this._textScrollPanel.Y = 1; - this._textScrollPanel.Width = state.ConsoleWidth; - this._textScrollPanel.Height = scrollBottom; this._textScrollPanel.Props = new TextScrollPanelProps { + X = 1, + Y = 1, + Width = state.ConsoleWidth, + Height = scrollBottom, Items = scrollItems, }; this._textScrollPanel.Render(); // Render the text panel for the last (dynamic) item just below the scroll region - this._textPanel.X = 1; - this._textPanel.Y = scrollBottom + 1; - this._textPanel.Width = state.ConsoleWidth; - this._textPanel.Height = textPanelHeight; this._textPanel.Props = new TextPanelProps { + X = 1, + Y = scrollBottom + 1, + Width = state.ConsoleWidth, + Height = textPanelHeight, Items = lastItems, }; this._textPanel.Render(); // Render queued input items between text panel and agent status int queuedPanelY = scrollBottom + textPanelHeight + 1; - this._queuedPanel.X = 1; - this._queuedPanel.Y = queuedPanelY; - this._queuedPanel.Width = state.ConsoleWidth; - this._queuedPanel.Height = queuedPanelHeight; this._queuedPanel.Props = new TextPanelProps { + X = 1, + Y = queuedPanelY, + Width = state.ConsoleWidth, + Height = queuedPanelHeight, Items = state.QueuedItems, }; this._queuedPanel.Render(); @@ -507,32 +515,41 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS int agentStatusY = queuedPanelY + queuedPanelHeight; if (showStatusAndHelp) { - this._agentStatus.X = 1; - this._agentStatus.Y = agentStatusY; - this._agentStatus.Width = state.ConsoleWidth; - this._agentStatus.Height = agentStatusHeight; - this._agentStatus.Props = agentStatusProps; + this._agentStatus.Props = agentStatusProps with + { + X = 1, + Y = agentStatusY, + Width = state.ConsoleWidth, + Height = agentStatusHeight, + }; this._agentStatus.Render(); } // Render the bottom rule + child below the agent status - this._rule.X = 1; - this._rule.Y = agentStatusY + agentStatusHeight; - this._rule.Props = ruleProps; + this._rule.Props = ruleProps with + { + X = 1, + Y = agentStatusY + agentStatusHeight, + }; this._rule.Render(); // Render the mode-and-help line below the bottom rule if (showStatusAndHelp) { - int modeAndHelpY = this._rule.Y + ruleHeight; - this._modeAndHelp.X = 1; - this._modeAndHelp.Y = modeAndHelpY; - this._modeAndHelp.Width = state.ConsoleWidth; - this._modeAndHelp.Height = modeAndHelpHeight; - this._modeAndHelp.Props = modeAndHelpProps; + int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight; + this._modeAndHelp.Props = modeAndHelpProps with + { + X = 1, + Y = modeAndHelpY, + Width = state.ConsoleWidth, + Height = modeAndHelpHeight, + }; this._modeAndHelp.Render(); } + // Clear the bottom padding line + System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight)); + // Position cursor for natural typing appearance this.PositionCursor(state); } @@ -546,7 +563,7 @@ private void PositionCursor(HarnessAppComponentState state) int textWidth = state.ConsoleWidth - promptLength; int textLength = state.InputText.Length; - int textInputY = this._rule.Y + 1; + int textInputY = (this._rule.Props?.Y ?? 0) + 1; if (textWidth <= 0 || textLength == 0) { @@ -564,7 +581,7 @@ private void PositionCursor(HarnessAppComponentState state) && state.ListSelectionIndex == state.ListSelectionOptions.Count) { int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0; - int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count; + int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count; int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1; System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol)); }