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 } { ANSI escape sequence parser state }
FParseState: TParseState; { Current parser state machine position } 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) } FMusicStr: string; { Accumulated ANSI music string (ESC[M..^N) }
{ Font metrics (measured from OEM charset paint font) } { Font metrics (measured from OEM charset paint font) }
@ -77,6 +78,7 @@ type
{ Scrollback view } { Scrollback view }
FScrollPos: Integer; { Lines scrolled back (0=live, >0=viewing history) } FScrollPos: Integer; { Lines scrolled back (0=live, >0=viewing history) }
FPendingScrolls: Integer; { Scroll-up count pending for ScrollDC coalescing }
{ Terminal modes } { Terminal modes }
FWrapMode: Boolean; { Auto-wrap at right margin (DEC ?7h/l) } 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; procedure ParseParamBuf(Buf: PChar; Len: Integer;
var Count: Integer); var Params: array of Integer; var Count: Integer);
var var
I: Integer; I: Integer;
Start: Integer; Value: Integer;
Token: string; InNum: Boolean;
begin begin
Count := 0; Count := 0;
if Length(S) = 0 then Value := 0;
Exit; InNum := False;
Start := 1; for I := 0 to Len - 1 do
for I := 1 to Length(S) do
begin begin
if S[I] = ';' then if Buf[I] = ';' then
begin begin
if Count <= High(Params) then if Count <= High(Params) then
begin begin
Token := Copy(S, Start, I - Start); if InNum then
if Length(Token) > 0 then Params[Count] := Value
Params[Count] := StrToIntDef(Token, 0)
else else
Params[Count] := 0; Params[Count] := 0;
Inc(Count); Inc(Count);
end; 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;
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 if Count <= High(Params) then
begin begin
Token := Copy(S, Start, Length(S) - Start + 1); if InNum then
if Length(Token) > 0 then Params[Count] := Value
Params[Count] := StrToIntDef(Token, 0)
else else
Params[Count] := 0; Params[Count] := 0;
Inc(Count); Inc(Count);
@ -475,13 +481,14 @@ begin
FAttrBlink := False; FAttrBlink := False;
FAttrReverse := False; FAttrReverse := False;
FParseState := psNormal; FParseState := psNormal;
FParamStr := ''; FParamLen := 0;
FMusicStr := ''; FMusicStr := '';
FCellWidth := 8; FCellWidth := 8;
FCellHeight := 16; FCellHeight := 16;
FBlinkOn := True; FBlinkOn := True;
FLastBlinkTick := GetTickCount; FLastBlinkTick := GetTickCount;
FScrollPos := 0; FScrollPos := 0;
FPendingScrolls := 0;
FWrapMode := True; FWrapMode := True;
FPaintFont := 0; FPaintFont := 0;
FStockFont := False; FStockFont := False;
@ -763,9 +770,10 @@ begin
FScreen.Add(Line); FScreen.Add(Line);
FScrollbarDirty := True; FScrollbarDirty := True;
{ Without ScrollDC, all rows must be re-rendered after a scroll } { Track pending scrolls for coalesced ScrollDC in FlipToScreen. }
{ because the on-screen pixels haven't moved to match FScreen. } { Multiple scrolls during one ParseData call collapse into a single }
FAllDirty := True; { ScrollDC call, then only the newly exposed bottom rows are rendered.}
Inc(FPendingScrolls);
end; end;
@ -888,7 +896,7 @@ var
P1: Integer; P1: Integer;
P2: Integer; P2: Integer;
begin begin
ParseParams(FParamStr, Params, Count); ParseParamBuf(@FParamBuf[0], FParamLen, Params, Count);
if Count > 0 then if Count > 0 then
P1 := Params[0] P1 := Params[0]
@ -1219,11 +1227,17 @@ end;
procedure TKPAnsi.FlipToScreen; procedure TKPAnsi.FlipToScreen;
{ Render dirty rows into the shared 8bpp DIB buffer, blasting each to the } { Render dirty rows into the shared 8bpp DIB buffer, blasting each to the }
{ screen via SetDIBitsToDevice immediately after rendering. One GDI call } { 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 var
DC: HDC; DC: HDC;
Row: Integer; Row: Integer;
GhostRow: Integer;
HasDirty: Boolean; HasDirty: Boolean;
ScrollR: TRect;
ClipR: TRect;
UpdateR: TRect;
begin begin
if not HandleAllocated then if not HandleAllocated then
Exit; Exit;
@ -1234,7 +1248,10 @@ begin
{ Scrollback view: force full redraw (line mapping changes) } { Scrollback view: force full redraw (line mapping changes) }
if FScrollPos <> 0 then if FScrollPos <> 0 then
begin
FAllDirty := True; FAllDirty := True;
FPendingScrolls := 0;
end;
{ Deferred scrollbar update (batched from DoScrollUp) } { Deferred scrollbar update (batched from DoScrollUp) }
if FScrollbarDirty then if FScrollbarDirty then
@ -1243,6 +1260,39 @@ begin
FScrollbarDirty := False; FScrollbarDirty := False;
end; 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 } { Dirty old cursor row to erase ghost when cursor moved between rows }
if FCursorRow <> FLastCursorRow then if FCursorRow <> FLastCursorRow then
begin begin
@ -1598,7 +1648,7 @@ var
I: Integer; I: Integer;
Code: Integer; Code: Integer;
begin begin
ParseParams(FParamStr, Params, Count); ParseParamBuf(@FParamBuf[0], FParamLen, Params, Count);
{ SGR with no parameters means reset } { SGR with no parameters means reset }
if Count = 0 then if Count = 0 then
@ -1746,7 +1796,7 @@ begin
case Ch of case Ch of
'[': '[':
begin begin
FParamStr := ''; FParamLen := 0;
FParseState := psCSI; FParseState := psCSI;
end; end;
else else
@ -1762,7 +1812,11 @@ begin
case Ch of case Ch of
'0'..'9', ';': '0'..'9', ';':
begin begin
FParamStr := FParamStr + Ch; if FParamLen < 32 then
begin
FParamBuf[FParamLen] := Ch;
Inc(FParamLen);
end;
end; end;
'?': '?':
begin begin
@ -1771,7 +1825,7 @@ begin
'M': 'M':
begin begin
{ Check if this is ANSI music: ESC[M starts music mode } { Check if this is ANSI music: ESC[M starts music mode }
if Length(FParamStr) = 0 then if FParamLen = 0 then
begin begin
FMusicStr := ''; FMusicStr := '';
FParseState := psMusic; FParseState := psMusic;
@ -1796,20 +1850,26 @@ begin
begin begin
case Ch of case Ch of
'0'..'9', ';': '0'..'9', ';':
FParamStr := FParamStr + Ch; begin
if FParamLen < 32 then
begin
FParamBuf[FParamLen] := Ch;
Inc(FParamLen);
end;
end;
'h': { Set Mode } 'h': { Set Mode }
begin begin
if FParamStr = '7' then if (FParamLen = 1) and (FParamBuf[0] = '7') then
FWrapMode := True FWrapMode := True
else if FParamStr = '25' then else if (FParamLen = 2) and (FParamBuf[0] = '2') and (FParamBuf[1] = '5') then
FCursorVisible := True; FCursorVisible := True;
FParseState := psNormal; FParseState := psNormal;
end; end;
'l': { Reset Mode } 'l': { Reset Mode }
begin begin
if FParamStr = '7' then if (FParamLen = 1) and (FParamBuf[0] = '7') then
FWrapMode := False FWrapMode := False
else if FParamStr = '25' then else if (FParamLen = 2) and (FParamBuf[0] = '2') and (FParamBuf[1] = '5') then
FCursorVisible := False; FCursorVisible := False;
FParseState := psNormal; FParseState := psNormal;
end; end;
@ -2180,7 +2240,7 @@ begin
FAttrBlink := False; FAttrBlink := False;
FAttrReverse := False; FAttrReverse := False;
FParseState := psNormal; FParseState := psNormal;
FParamStr := ''; FParamLen := 0;
FMusicStr := ''; FMusicStr := '';
FWrapMode := True; FWrapMode := True;
FSaveCurRow := 0; FSaveCurRow := 0;