Skip to content

Reduce MarkupControl memory allocations and add scroll offset notifications - #93

Merged
nickprotop merged 9 commits into
nickprotop:masterfrom
OzeroHAX:optimize-markup-memory
Nov 4, 2026
Merged

nickprotop merged 9 commits into
nickprotop:masterfrom
OzeroHAX:optimize-markup-memory

Conversation

@OzeroHAX

@OzeroHAX OzeroHAX commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR reduces memory allocations in MarkupControl rendering and adds scroll offset notifications needed by virtualization consumers.

Changes

  • Reduce temporary allocations in markup parsing and rendering paths.
  • Add incremental Markdown processing for streamed content.
  • Refactor styled text and painted row handling to retain reusable state.
  • Add VerticalScrollOffsetChanged notifications for scrollable controls.
  • Add tests for PreviousOffset, Offset, sequential changes, and unchanged offsets.

Type

  • Enhancement
  • Bug fix
  • New feature
  • Documentation
  • Refactoring

Testing

  • Tested with DemoApp
  • Tested with relevant tests
  • Release build succeeds without warnings or errors
  • Full test suite passes: 7,974 passed, 0 failed, 0 skipped
  • NativeAOT publish not run locally

Commands run:

dotnet build SharpConsoleUI/SharpConsoleUI.csproj -c Release --no-incremental
dotnet test SharpConsoleUI.Tests/SharpConsoleUI.Tests.csproj -c Release --no-build

Code quality

  • No breaking changes to public API
  • No console output added to library code
  • No magic numbers introduced in the new packed-text structures
  • UI mutations remain on the existing UI-thread paths
  • New source files carry the required file-header banner

Screenshots are not applicable: this changes parsing/cache behavior and adds a notification API without changing visual design.

I use this new API in my application for virtual scrolling and memory optimization during long-running sessions.

@nickprotop

Copy link
Copy Markdown
Owner

Thanks for this, and for working through the headers, formatting and docs across the six commits. The allocation work is the kind of change that is easy to get subtly wrong and you kept the public surface intact throughout.

Verified on c8211a4c: release build clean, suite green, format gate passes. Also ran the two left unticked, both fine. NativeAOT publishes and the smoke binary exits 0 across 60 controls. DemoApp runs headless with no exceptions.

WriteTextRowClipped is the one thing I would not merge without tests: it affects the core of the rendering pipeline, the cell buffer itself. It computes its own clip bounds with -1 and +1 adjustments, has a single caller, and nothing covers it. I wrote some expecting an off-by-one: 25 cases painting the same cells through both it and WriteCellsClipped, at every clip offset, with wide glyphs straddling the edge. They all pass, so the arithmetic is right. Worth having anyway, since that path fails silently and a CJK line at a window edge is where it shows. The -1 on from can be removed without failing any of them, so a note on why it is there would help.

The event tests cover the values but not that it fires from real scrolling. The second file drives keys through the input dispatch and a wheel through the control's mouse path, then asserts the offset survives a re-render, that successive notifications chain, and that scrolling against the top raises nothing.

SharpConsoleUI.Tests/Layout/WriteTextRowClippedTests.cs
// -----------------------------------------------------------------------
// ConsoleEx - A simple console window system for .NET Core
//
// Author: Nikolaos Protopapas
// Email: nikolaos.protopapas@gmail.com
// License: MIT
// -----------------------------------------------------------------------

using System.Collections.Generic;
using System.Linq;
using SharpConsoleUI.Layout;
using SharpConsoleUI.Parsing;
using Xunit;

namespace SharpConsoleUI.Tests.Layout;

/// <summary>
/// <see cref="CharacterBuffer.WriteTextRowClipped"/> against the path it replaces.
/// </summary>
/// <remarks>
/// The fast row writer computes its own start and end from the clip rectangle, with adjustments
/// that <see cref="CharacterBuffer.WriteCellsClipped"/> does not need because it walks every cell.
/// The two must paint identically: the only thing that may differ is how much they allocate.
/// A wide glyph straddling the clip edge is where an off-by-one in that arithmetic shows up, and
/// it is silent - a CJK line at a window edge loses or gains a column and nothing fails.
/// </remarks>
public class WriteTextRowClippedTests
{
	private const int Width = 20;
	private const int Height = 3;

	private static CharacterBuffer Fresh() => new(Width, Height);

	private static string RowText(CharacterBuffer buffer, int y)
	{
		var sb = new System.Text.StringBuilder();
		for (int x = 0; x < Width; x++)
			sb.Append(buffer.GetCell(x, y).Character.ToString());
		return sb.ToString();
	}

	/// <summary>Paints the same cells both ways and asserts the two buffers agree, cell for cell.</summary>
	private static void AssertSamePainting(string markup, int x, LayoutRect clip)
	{
		// The same text through both paths: Parse for the enumerable one, and the styled
		// representation the fast one takes. Both describe the identical cells.
		var cells = MarkupParser.Parse(markup, Color.White, Color.Black);
		var styled = MarkupParser.ParseStyledText(markup, Color.White, Color.Black);
		var row = new PaintedTextRow(new StyledRow(styled, 0, cells.Count));

		var viaEnumerable = Fresh();
		viaEnumerable.WriteCellsClipped(x, 1, cells, clip);

		var viaRow = Fresh();
		viaRow.WriteTextRowClipped(x, 1, row, clip);

		Assert.Equal(RowText(viaEnumerable, 1), RowText(viaRow, 1));
	}

	public static IEnumerable<object[]> ClipWindows()
	{
		// Every left edge across the buffer, so a wide glyph is straddled at each offset in turn.
		for (int left = 0; left <= 8; left++)
			yield return new object[] { left };
	}

	/// <summary>
	/// Narrow text, every clip offset. If the start and end arithmetic is off by one anywhere, one
	/// of these windows paints a column the slow path does not.
	/// </summary>
	[Theory]
	[MemberData(nameof(ClipWindows))]
	public void NarrowText_PaintsTheSameAsTheEnumerablePath(int left)
	{
		AssertSamePainting("abcdefghij", x: 2, new LayoutRect(left, 0, Width - left, Height));
	}

	/// <summary>
	/// WIDE GLYPHS, every clip offset. Each takes two columns, so a clip edge can fall between a
	/// glyph's two halves: the writer has to decide whether that glyph is in or out, and both paths
	/// must decide the same way.
	/// </summary>
	[Theory]
	[MemberData(nameof(ClipWindows))]
	public void WideGlyphs_StraddlingTheClipEdge_PaintTheSame(int left)
	{
		AssertSamePainting("你好世界", x: 2, new LayoutRect(left, 0, Width - left, Height));
	}

	/// <summary>A clip that ends mid-glyph, from the right rather than the left.</summary>
	[Theory]
	[InlineData(4)]
	[InlineData(5)]
	[InlineData(6)]
	[InlineData(7)]
	public void WideGlyphs_ClippedOnTheRight_PaintTheSame(int right)
	{
		AssertSamePainting("你好世界", x: 2, new LayoutRect(0, 0, right, Height));
	}

	/// <summary>Text starting left of the clip, so the writer must skip into the middle of the row.</summary>
	[Fact]
	public void TextStartingBeforeTheClip_PaintsTheSame()
	{
		AssertSamePainting("abcdefghij", x: 0, new LayoutRect(4, 0, 10, Height));
	}

	/// <summary>Wide text starting left of the clip: skipping and straddling at once.</summary>
	[Fact]
	public void WideTextStartingBeforeTheClip_PaintsTheSame()
	{
		AssertSamePainting("你好世界", x: 0, new LayoutRect(3, 0, 10, Height));
	}

	/// <summary>A row wholly outside the clip paints nothing, as the enumerable path does.</summary>
	[Fact]
	public void RowOutsideTheClip_PaintsNothing()
	{
		AssertSamePainting("abcdefghij", x: 2, new LayoutRect(0, 2, Width, 1));
	}
}
SharpConsoleUI.Tests/Controls/ScrollOffsetNotificationRealUsageTests.cs
// -----------------------------------------------------------------------
// ConsoleEx - A simple console window system for .NET Core
//
// Author: Nikolaos Protopapas
// Email: nikolaos.protopapas@gmail.com
// License: MIT
// -----------------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Drawing;
using SharpConsoleUI.Builders;
using SharpConsoleUI.Controls;
using SharpConsoleUI.Core;
using SharpConsoleUI.Drivers;
using SharpConsoleUI.Events;
using SharpConsoleUI.Tests.Infrastructure;
using Xunit;

namespace SharpConsoleUI.Tests.Controls;

/// <summary>
/// <see cref="ScrollablePanelControl.VerticalScrollOffsetChanged"/> as a consumer actually meets it.
/// </summary>
/// <remarks>
/// <para>
/// The event exists so a virtualizing consumer can keep a window over a long transcript in step with
/// what is on screen. That consumer never sets the offset itself: it scrolls, or the user does, by
/// key and by wheel. A test that assigns the property proves the event carries the right numbers and
/// says nothing about whether it fires when the panel is actually scrolled.
/// </para>
/// <para>
/// So this drives the real paths: keys through the input dispatch, and a wheel event through the
/// control's own mouse handling, inside a hosted window with content long enough to scroll, and
/// asserts the notification survives a re-render.
/// </para>
/// </remarks>
public class ScrollOffsetNotificationRealUsageTests
{
	private const int Width = 40;
	private const int Height = 12;

	/// <summary>A panel in a real window, holding more content than fits.</summary>
	private static (ConsoleWindowSystem system, ScrollablePanelControl panel, List<ScrollOffsetChangedEventArgs> seen) Host()
	{
		var system = TestWindowSystemBuilder.CreateTestSystem(Width, Height);
		var window = new Window(system) { Left = 0, Top = 0, Width = Width, Height = Height };

		var panel = SharpConsoleUI.Builders.Controls.ScrollablePanel().Build();
		for (int i = 0; i < 60; i++)
			panel.AddControl(new MarkupControl(new List<string> { $"line {i:D2}" }));

		window.AddControl(panel);
		system.AddWindow(window);
		window.FocusManager.SetFocus(panel, FocusReason.Programmatic);
		system.Render.UpdateDisplay();

		var seen = new List<ScrollOffsetChangedEventArgs>();
		panel.VerticalScrollOffsetChanged += (_, e) => seen.Add(e);
		return (system, panel, seen);
	}

	/// <summary>
	/// A key through the real dispatch scrolls the panel and tells the consumer, and the offset the
	/// event reported is the offset the panel still has after the frame is composed.
	/// </summary>
	[Fact]
	public void ScrollingByKey_RaisesTheEvent_AndTheOffsetSurvivesARerender()
	{
		var (system, panel, seen) = Host();

		for (int i = 0; i < 5; i++)
		{
			system.InputStateService.EnqueueKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false));
			system.Input.ProcessInput();
		}

		system.Render.UpdateDisplay();

		Assert.NotEmpty(seen);

		// The last notification must describe where the panel actually ended up: a consumer that
		// trusted a stale number would window the wrong part of the transcript.
		Assert.Equal(panel.VerticalScrollOffset, seen[^1].Offset);
		GC.KeepAlive(system);
	}

	/// <summary>
	/// Each notification starts where the previous one ended. A virtualizing consumer uses the pair
	/// to move its window by a delta, so a gap between them would lose rows.
	/// </summary>
	[Fact]
	public void SuccessiveScrolls_ChainPreviousOffsetToOffset()
	{
		var (system, _, seen) = Host();

		for (int i = 0; i < 6; i++)
		{
			system.InputStateService.EnqueueKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false));
			system.Input.ProcessInput();
		}

		system.Render.UpdateDisplay();

		Assert.NotEmpty(seen);
		for (int i = 1; i < seen.Count; i++)
			Assert.Equal(seen[i - 1].Offset, seen[i].PreviousOffset);
		GC.KeepAlive(system);
	}

	/// <summary>The wheel is the other way a user scrolls, and it must notify the same way a key does.</summary>
	[Fact]
	public void ScrollingByWheel_RaisesTheEvent()
	{
		var (system, panel, seen) = Host();

		var at = new Point(2, 2);
		panel.ProcessMouseEvent(new MouseEventArgs(
			new List<MouseFlags> { MouseFlags.WheeledDown }, at, at, at));

		system.Render.UpdateDisplay();

		Assert.NotEmpty(seen);
		Assert.Equal(panel.VerticalScrollOffset, seen[^1].Offset);
		GC.KeepAlive(system);
	}

	/// <summary>
	/// An offset that did not move raises nothing. Scrolling up at the top is the common case, and a
	/// consumer woken for it would rebuild its window for no reason on every key the user holds.
	/// </summary>
	[Fact]
	public void ScrollingAgainstTheTop_RaisesNothing()
	{
		var (system, _, seen) = Host();

		for (int i = 0; i < 3; i++)
		{
			system.InputStateService.EnqueueKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false));
			system.Input.ProcessInput();
		}

		system.Render.UpdateDisplay();

		Assert.Empty(seen);
		GC.KeepAlive(system);
	}
}

Take them, rewrite them, or say the word and I will push them to your branch.

WriteTextRowClipped computes its own clip bounds and had a single caller and
no tests. It paints the cell buffer directly, so a wrong column there is
silent: a CJK line at a window edge loses or gains a column and nothing
fails. The 25 cases paint the same cells through it and through
WriteCellsClipped at every clip offset, with wide glyphs straddling each
edge, and compare the buffers.

The arithmetic is right as written. The -1 and +1 are wider bounds than
needed rather than corrections: either can be removed with every case still
passing, because the clamp and the per-cell check in WriteCellsClippedCore
absorb the difference. Shifting the write position by one column fails 24 of
the 25, so the tests do hold the painting itself.

The offset tests drive the real paths. VerticalScrollOffsetChanged exists so
a virtualizing consumer can follow the viewport, and that consumer never
sets the offset itself: it scrolls, or the user does. So these send keys
through the input dispatch and a wheel through the control's mouse path,
inside a hosted window, and assert the reported offset is the one the panel
still has after the frame is composed.
@nickprotop
nickprotop merged commit d73c82a into nickprotop:master Nov 4, 2026
2 checks passed
@nickprotop

Copy link
Copy Markdown
Owner

Thanks, this is great work. Merged.

One thing I left open, worth a follow-up note rather than a change: I mutated both adjustments in WriteTextRowClipped. Either the -1 on from or the +1 on to can be deleted with all 25 new cases still passing, because the clamp and the per-cell check in WriteCellsClippedCore absorb the difference. Shifting the write position by one column, though, fails 24 of them, so the painting is pinned and the arithmetic is right as written.

So they read as wider bounds rather than corrections. If that is what you intended, a line saying so would stop someone later reading them as an off-by-one and "fixing" it. If they are load-bearing for a case the tests miss, that case is worth adding.

@nickprotop

Copy link
Copy Markdown
Owner

This is out in 2.10.8.

I measured the scroll path after merging, since I had only eyeballed it. 600 scrolls over 400 markup lines mixing CJK, emoji, combining marks and per-word tags, headless, three alternating runs each way:

before after
per scroll 3072 µs 1684 µs
allocated per scroll 1900 KB 1390 KB
gen0 collections 139 102

45% faster and 27% less allocated, with the parse cache warm in both, so this is the painting itself getting cheaper rather than fewer parses. MarkupCacheDemo is visibly smoother to scroll now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants