Eliminate heap allocs in ANSI parser, add ScrollDC for scroll rendering

Replace FParamStr string with fixed char buffer (FParamBuf/FParamLen)
to eliminate per-character heap allocations during CSI escape sequence
parsing. Replace ParseParams (Copy + StrToIntDef per token) with
ParseParamBuf that parses integers directly from the char buffer with
zero allocations.

Replace FAllDirty in DoScrollUp with FPendingScrolls counter. In
FlipToScreen, coalesce pending scrolls into a single ScrollDC call
that shifts on-screen pixels, then only render the newly exposed
bottom rows. Reduces per-scroll GDI cost from 25 SetDIBitsToDevice
calls to 1-3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Duensing 2026-03-02 16:03:45 -06:00
parent 02e01848d6
commit 75d598c4c5

View file

@ -64,7 +64,8 @@ type
{ ANSI escape sequence parser state }
FParseState: TParseState; { Current parser state machine position }
FParamStr: string; { Accumulated CSI parameter digits/semicolons }
FParamBuf: array[0..31] of Char; { CSI parameter digits/semicolons }
FParamLen: Integer; { Current length of FParamBuf }
FMusicStr: string; { Accumulated ANSI music string (ESC[M..^N) }
{ Font metrics (measured from OEM charset paint font) }
@ -77,6 +78,7 @@ type
{ Scrollback view }
FScrollPos: Integer; { Lines scrolled back (0=live, >0=viewing history) }
FPendingScrolls: Integer; { Scroll-up count pending for ScrollDC coalescing }
{ Terminal modes }
FWrapMode: Boolean; { Auto-wrap at right margin (DEC ?7h/l) }
@ -227,42 +229,46 @@ type
{ ----------------------------------------------------------------------- }
{ Helper: parse semicolon-delimited parameter string into integer array }
{ Helper: parse semicolon-delimited parameters from char buffer }
{ Zero-allocation: parses integers directly without Copy or StrToIntDef. }
{ ----------------------------------------------------------------------- }
procedure ParseParams(const S: string; var Params: array of Integer;
var Count: Integer);
procedure ParseParamBuf(Buf: PChar; Len: Integer;
var Params: array of Integer; var Count: Integer);
var
I: Integer;
Start: Integer;
Token: string;
Value: Integer;
InNum: Boolean;
begin
Count := 0;
if Length(S) = 0 then
Exit;
Start := 1;
for I := 1 to Length(S) do
Value := 0;
InNum := False;
for I := 0 to Len - 1 do
begin
if S[I] = ';' then
if Buf[I] = ';' then
begin
if Count <= High(Params) then
begin
Token := Copy(S, Start, I - Start);
if Length(Token) > 0 then
Params[Count] := StrToIntDef(Token, 0)
if InNum then
Params[Count] := Value
else
Params[Count] := 0;
Inc(Count);
end;
Start := I + 1;
Value := 0;
InNum := False;
end
else if (Buf[I] >= '0') and (Buf[I] <= '9') then
begin
Value := Value * 10 + (Ord(Buf[I]) - Ord('0'));
InNum := True;
end;
end;
{ Last token after final semicolon (or entire string if no semicolons) }
{ Last value after final semicolon (or entire buffer if no semicolons) }
if Count <= High(Params) then
begin
Token := Copy(S, Start, Length(S) - Start + 1);
if Length(Token) > 0 then
Params[Count] := StrToIntDef(Token, 0)
if InNum then
Params[Count] := Value
else
Params[Count] := 0;
Inc(Count);
@ -475,13 +481,14 @@ begin
FAttrBlink := False;
FAttrReverse := False;
FParseState := psNormal;
FParamStr := '';
FParamLen := 0;
FMusicStr := '';
FCellWidth := 8;
FCellHeight := 16;
FBlinkOn := True;
FLastBlinkTick := GetTickCount;
FScrollPos := 0;
FPendingScrolls := 0;
FWrapMode := True;
FPaintFont := 0;
FStockFont := False;
@ -763,9 +770,10 @@ begin
FScreen.Add(Line);
FScrollbarDirty := True;
{ Without ScrollDC, all rows must be re-rendered after a scroll }
{ because the on-screen pixels haven't moved to match FScreen. }
FAllDirty := True;
{ Track pending scrolls for coalesced ScrollDC in FlipToScreen. }
{ Multiple scrolls during one ParseData call collapse into a single }
{ ScrollDC call, then only the newly exposed bottom rows are rendered.}
Inc(FPendingScrolls);
end;
@ -888,7 +896,7 @@ var
P1: Integer;
P2: Integer;
begin
ParseParams(FParamStr, Params, Count);
ParseParamBuf(@FParamBuf[0], FParamLen, Params, Count);
if Count > 0 then
P1 := Params[0]
@ -1219,11 +1227,17 @@ end;
procedure TKPAnsi.FlipToScreen;
{ Render dirty rows into the shared 8bpp DIB buffer, blasting each to the }
{ screen via SetDIBitsToDevice immediately after rendering. One GDI call }
{ per dirty row, zero for the pixel expansion itself. }
{ per dirty row, zero for the pixel expansion itself. Coalesced ScrollDC }
{ shifts on-screen pixels to match FScreen after scrolling, reducing the }
{ per-scroll GDI cost from 25 rows to just the newly exposed rows. }
var
DC: HDC;
Row: Integer;
HasDirty: Boolean;
DC: HDC;
Row: Integer;
GhostRow: Integer;
HasDirty: Boolean;
ScrollR: TRect;
ClipR: TRect;
UpdateR: TRect;
begin
if not HandleAllocated then
Exit;
@ -1234,7 +1248,10 @@ begin
{ Scrollback view: force full redraw (line mapping changes) }
if FScrollPos <> 0 then
begin
FAllDirty := True;
FPendingScrolls := 0;
end;
{ Deferred scrollbar update (batched from DoScrollUp) }
if FScrollbarDirty then
@ -1243,6 +1260,39 @@ begin
FScrollbarDirty := False;
end;
{ Coalesced ScrollDC: shift on-screen pixels to match FScreen after }
{ one or more DoScrollUp calls, then only render the newly exposed rows. }
if (FPendingScrolls > 0) and not FAllDirty then
begin
if FPendingScrolls < FRows then
begin
ScrollR.Left := 0;
ScrollR.Top := 0;
ScrollR.Right := FCols * FCellWidth;
ScrollR.Bottom := FRows * FCellHeight;
ClipR := ScrollR;
DC := GetDC(Handle);
ScrollDC(DC, 0, -(FPendingScrolls * FCellHeight),
ScrollR, ClipR, 0, @UpdateR);
ReleaseDC(Handle, DC);
{ Dirty the newly exposed bottom rows }
for Row := FRows - FPendingScrolls to FRows - 1 do
begin
if Row >= 0 then
FDirtyRow[Row] := True;
end;
{ Dirty row that now shows cursor ghost from pre-scroll pixels }
GhostRow := FCursorRow - FPendingScrolls;
if (GhostRow >= 0) and (GhostRow < FRows) then
FDirtyRow[GhostRow] := True;
{ Sync FLastCursorRow since pixel positions shifted }
FLastCursorRow := FCursorRow;
end
else
FAllDirty := True;
FPendingScrolls := 0;
end;
{ Dirty old cursor row to erase ghost when cursor moved between rows }
if FCursorRow <> FLastCursorRow then
begin
@ -1598,7 +1648,7 @@ var
I: Integer;
Code: Integer;
begin
ParseParams(FParamStr, Params, Count);
ParseParamBuf(@FParamBuf[0], FParamLen, Params, Count);
{ SGR with no parameters means reset }
if Count = 0 then
@ -1746,7 +1796,7 @@ begin
case Ch of
'[':
begin
FParamStr := '';
FParamLen := 0;
FParseState := psCSI;
end;
else
@ -1762,7 +1812,11 @@ begin
case Ch of
'0'..'9', ';':
begin
FParamStr := FParamStr + Ch;
if FParamLen < 32 then
begin
FParamBuf[FParamLen] := Ch;
Inc(FParamLen);
end;
end;
'?':
begin
@ -1771,7 +1825,7 @@ begin
'M':
begin
{ Check if this is ANSI music: ESC[M starts music mode }
if Length(FParamStr) = 0 then
if FParamLen = 0 then
begin
FMusicStr := '';
FParseState := psMusic;
@ -1796,20 +1850,26 @@ begin
begin
case Ch of
'0'..'9', ';':
FParamStr := FParamStr + Ch;
begin
if FParamLen < 32 then
begin
FParamBuf[FParamLen] := Ch;
Inc(FParamLen);
end;
end;
'h': { Set Mode }
begin
if FParamStr = '7' then
if (FParamLen = 1) and (FParamBuf[0] = '7') then
FWrapMode := True
else if FParamStr = '25' then
else if (FParamLen = 2) and (FParamBuf[0] = '2') and (FParamBuf[1] = '5') then
FCursorVisible := True;
FParseState := psNormal;
end;
'l': { Reset Mode }
begin
if FParamStr = '7' then
if (FParamLen = 1) and (FParamBuf[0] = '7') then
FWrapMode := False
else if FParamStr = '25' then
else if (FParamLen = 2) and (FParamBuf[0] = '2') and (FParamBuf[1] = '5') then
FCursorVisible := False;
FParseState := psNormal;
end;
@ -2180,7 +2240,7 @@ begin
FAttrBlink := False;
FAttrReverse := False;
FParseState := psNormal;
FParamStr := '';
FParamLen := 0;
FMusicStr := '';
FWrapMode := True;
FSaveCurRow := 0;