eterm: optimize terminal output processing

* Add fast paths for printable ASCII, row clearing, scrolling, history recycling, dirty tracking, and history width detection.
* Use runtime-dispatched AVX2 and NEON scanning for trailing blank cells, increase the PTY read buffer, and bound PTY processing and presentation to keep sustained output responsive.
* Preserve selection redraws, wide glyph cleanup, VT100 graphics handling, history reflow, and final buffered PTY output after process exit.
* Add regression coverage for deferred redraws, saturated reads, process termination, history scrolling, wide glyphs, and graphics character sets.
This commit is contained in:
Martín Lucas Golini
2026-08-22 20:28:45 -03:00
parent 7f0633afb6
commit 7ffcccb9cb
4 changed files with 441 additions and 41 deletions

View File

@@ -61,15 +61,20 @@ constexpr int STR_ARG_SIZ = ESC_ARG_SIZ;
/* Internal representation of the screen */
struct Term {
int row{ 0 }; /* nb row */
int col{ 0 }; /* nb col */
Line* line{ nullptr }; /* screen */
Line* alt{ nullptr }; /* alternate screen */
Line* hist{ nullptr }; /* history buffer */
int histcursize{ 0 }; /* history current size */
int histsize{ 0 }; /* history max size */
int histi{ 0 }; /* history index */
int histlen{ 0 }; /* history valid length */
int row{ 0 }; /* nb row */
int col{ 0 }; /* nb col */
Line* line{ nullptr }; /* screen */
Line* alt{ nullptr }; /* alternate screen */
Line* hist{ nullptr }; /* history buffer */
int histcursize{ 0 }; /* history current size */
int histsize{ 0 }; /* history max size */
int histi{ 0 }; /* history index */
int histlen{ 0 }; /* history valid length */
/* Allocation width of each history line. All slots are (re)allocated together
* on every resize/reflow, so a single shared capacity is enough to know when a
* recycled ring-buffer slot can be overwritten in place instead of being freed
* and reallocated for every pushed line (hot path on fast-scrolling output). */
int histcapacity{ 0 };
int max_width{ 0 }; /* max width of lines in history */
int scr{ 0 }; /* scroll back */
int* dirty{ nullptr }; /* dirtyness of lines */
@@ -271,12 +276,15 @@ class TerminalEmulator final {
Clock mPendingPtyResizeClock;
bool mDirty{ true };
bool mAllDirty{ true };
uint8_t mDeferredPresentationBatches{ 0 };
Clock mPresentationClock;
bool mAllowMemoryTrimnming{ false };
int mExitCode;
enum { STARTING = 0, RUNNING, TERMINATED } mStatus;
char mBuf[8192];
char mBuf[4 * 8192];
int mBuflen;
Term mTerm;
@@ -339,7 +347,12 @@ class TerminalEmulator final {
void treset();
void tscrollup( int, int, int );
void tscrolldown( int, int );
void historyUpdateMaxWidth( Line line, int col );
void historyPush( Line line, int col );
/* Zero-copy variant: trades buffer ownership between the screen row and the
* recycled history slot. The caller must blank the returned screen row
* afterwards (tscrollup does this via tclearregion). */
void historyStealPush( Line* lineSlot, int col );
void historyReflow( int old_col, int new_col );
void historyPopToScreen( int loaded, int col );
void tsetattr( int*, int );

View File

@@ -150,10 +150,10 @@ typedef unsigned short ushort;
typedef uint_least32_t Rune;
struct TerminalGlyph {
Rune u{ 0 }; /* character code */
ushort mode{ 0 }; /* attribute flags */
uint32_t fg{ 0 }; /* foreground */
uint32_t bg{ 0 }; /* background */
Rune u{ 0 }; /* character code */
uint32_t mode{ 0 }; /* attribute flags */
uint32_t fg{ 0 }; /* foreground */
uint32_t bg{ 0 }; /* background */
bool operator==( const TerminalGlyph& r ) {
return u == r.u && mode == r.mode && fg == r.fg && bg == r.bg;

View File

@@ -38,13 +38,16 @@
#include <eepp/core/memorymanager.hpp>
#include <eepp/network/uri.hpp>
#include <eepp/system/cpu.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
using namespace EE::Network;
using namespace EE::System;
#include <algorithm>
#include <assert.h>
#include <bit>
#include <cmath>
#include <ctype.h>
#include <errno.h>
@@ -58,6 +61,12 @@ using namespace EE::System;
#include <string.h>
#include <sys/types.h>
#if defined( EE_ARCH_X86_64 )
#include <immintrin.h>
#elif defined( EE_ARCH_ARM64 )
#include <arm_neon.h>
#endif
#if EE_PLATFORM == EE_PLATFORM_LINUX
// For malloc_trim, which is a GNU extension
extern "C" {
@@ -67,6 +76,48 @@ extern "C" {
namespace eterm { namespace Terminal {
#if defined( EE_ARCH_X86_64 )
#if defined( __GNUC__ ) || defined( __clang__ )
__attribute__( ( target( "avx2" ) ) )
#endif
static int trailingNonSpaceWidthAVX2( Line line, int width, int minimumWidth ) {
const __m256i offsets = _mm256_set_epi32( 112, 96, 80, 64, 48, 32, 16, 0 );
const __m256i spaces = _mm256_set1_epi32( ' ' );
while ( width - minimumWidth >= 8 ) {
int start = width - 8;
const __m256i codepoints =
_mm256_i32gather_epi32( reinterpret_cast<const int*>( line + start ), offsets, 1 );
unsigned int nonSpaces =
( ~static_cast<unsigned int>( _mm256_movemask_ps(
_mm256_castsi256_ps( _mm256_cmpeq_epi32( codepoints, spaces ) ) ) ) ) &
0xFFu;
if ( nonSpaces != 0 )
return start + std::bit_width( nonSpaces );
width = start;
}
while ( width > minimumWidth && line[width - 1].u == ' ' )
--width;
return width;
}
#elif defined( EE_ARCH_ARM64 )
static int trailingNonSpaceWidthNEON( Line line, int width, int minimumWidth ) {
const uint32x4_t spaces = vdupq_n_u32( ' ' );
while ( width - minimumWidth >= 4 ) {
int start = width - 4;
const uint32x4x4_t glyphs = vld4q_u32( reinterpret_cast<const uint32_t*>( line + start ) );
if ( vminvq_u32( vceqq_u32( glyphs.val[0], spaces ) ) != UINT32_MAX ) {
for ( int x = width - 1; x >= start; --x )
if ( line[x].u != ' ' )
return x + 1;
}
width = start;
}
while ( width > minimumWidth && line[width - 1].u == ' ' )
--width;
return width;
}
#endif
/* identification sequence returned in DA and DECID */
static const char* vtiden = "\033[?6c";
@@ -382,8 +433,15 @@ int TerminalEmulator::tlinelen( int y ) const {
int TerminalEmulator::tlinelen( Line line, int col ) const {
int i = col;
if ( line[i - 1].mode & ATTR_WRAP )
if ( i > 0 && ( line[i - 1].mode & ATTR_WRAP ) )
return i;
/* Scan backwards in 4-glyph steps while possible: the trailing-run check is
* branch-light and the common cases are either a full-width wrapped line
* or a short trailing space run, so this keeps the scan cheap without
* changing semantics. */
while ( i >= 4 && line[i - 1].u == ' ' && line[i - 2].u == ' ' && line[i - 3].u == ' ' &&
line[i - 4].u == ' ' )
i -= 4;
while ( i > 0 && line[i - 1].u == ' ' )
--i;
return i;
@@ -769,6 +827,7 @@ void TerminalEmulator::clearHistory() {
mTerm.histi = 0;
mTerm.histlen = 0;
mTerm.max_width = 0;
mTerm.histcapacity = 0;
mTerm.scr = 0;
trimMemory();
}
@@ -859,8 +918,12 @@ void TerminalEmulator::tsetdirt( int top, int bot ) {
LIMIT( top, 0, mTerm.row - 1 );
LIMIT( bot, 0, mTerm.row - 1 );
mDirty = true;
if ( mAllDirty )
return;
for ( i = top; i <= bot; i++ )
mTerm.dirty[i] = 1;
if ( top == 0 && bot == mTerm.row - 1 )
mAllDirty = true;
}
void TerminalEmulator::tsetdirtattr( int attr ) {
@@ -971,7 +1034,6 @@ void TerminalEmulator::tscrolldown( int top, int n ) {
void TerminalEmulator::tscrollup( int top, int n, int copyhist ) {
int i;
Line temp;
LIMIT( n, 0, mTerm.bot - top + 1 );
@@ -982,7 +1044,7 @@ void TerminalEmulator::tscrollup( int top, int n, int copyhist ) {
mTerm.scr += n;
for ( i = 0; i < n; i++ )
historyPush( mTerm.line[top + i], mTerm.col );
historyStealPush( &mTerm.line[top + i], mTerm.col );
if ( attop )
mTerm.scr = mTerm.histlen;
@@ -993,25 +1055,71 @@ void TerminalEmulator::tscrollup( int top, int n, int copyhist ) {
tclearregion( 0, top, mTerm.col - 1, top + n - 1, copyhist != 0 );
tsetdirt( top + n, mTerm.bot );
for ( i = top; i <= mTerm.bot - n; i++ ) {
temp = mTerm.line[i];
mTerm.line[i] = mTerm.line[i + n];
mTerm.line[i + n] = temp;
/* Move the whole live region with a single rotation instead of swapping line
* pointers one by one: this loop is the hot path when large amounts of output
* stream through the terminal. The semantics match the original per-line
* swap: the vacated bottom slots receive the pointers of the cleared top
* rows (a rotation), so every slot keeps owning exactly one unique buffer.
* The per-line dirty flags are gathered first because tsetdirt() above
* already marked the rows that must be redrawn and the move below would
* otherwise clobber that information for the rows being shifted. */
int shift = mTerm.bot - n - top + 1;
if ( shift > 0 ) {
std::rotate( &mTerm.line[top], &mTerm.line[top + n], &mTerm.line[mTerm.bot + 1] );
/* After a plain scroll every shifted-in row is either a cleared row (top
* region) or an untouched row moving up; both keep their previous dirty
* state except that the vacated bottom rows must be redrawn with the
* cleared content. */
for ( i = mTerm.bot; i > mTerm.bot - n && i >= top; i-- )
mTerm.dirty[i] = 1;
}
/* A full-screen scroll dirties the cleared rows and every shifted row. Once
* presentation is deferred, later scrolls in the same PTY burst can skip
* repeatedly walking the complete dirty array. */
if ( top == 0 && mTerm.bot == mTerm.row - 1 )
mAllDirty = true;
if ( mTerm.scr == 0 )
selscroll( top, -n );
onScrollPositionChange();
}
void TerminalEmulator::historyUpdateMaxWidth( Line line, int col ) {
if ( mTerm.max_width >= col )
return;
if ( line[col - 1].mode & ATTR_WRAP ) {
mTerm.max_width = col;
return;
}
/* Only inspect cells that could extend the current maximum. This preserves
* tlinelen() semantics for lines with trailing spaces without repeatedly
* scanning the already-known prefix of short history lines. */
int width = col;
#if defined( EE_ARCH_X86_64 )
if ( EE::System::CPU::hasAVX2() )
width = trailingNonSpaceWidthAVX2( line, width, mTerm.max_width );
else
#elif defined( EE_ARCH_ARM64 )
if ( EE::System::CPU::hasNEON() )
width = trailingNonSpaceWidthNEON( line, width, mTerm.max_width );
else
#endif
while ( width > mTerm.max_width && line[width - 1].u == ' ' )
--width;
if ( width > mTerm.max_width )
mTerm.max_width = width;
}
void TerminalEmulator::historyPush( Line line, int col ) {
if ( mTerm.histsize <= 0 )
return;
int width = tlinelen( line, col );
if ( width > mTerm.max_width )
mTerm.max_width = width;
historyUpdateMaxWidth( line, col );
mTerm.histi = ( mTerm.histi + 1 ) % mTerm.histsize;
if ( mTerm.histlen < mTerm.histsize ) {
@@ -1023,12 +1131,57 @@ void TerminalEmulator::historyPush( Line line, int col ) {
mTerm.hist[i] = nullptr;
mTerm.histcursize = newSize;
}
} else if ( mTerm.hist[mTerm.histi] ) {
eeSAFE_FREE( mTerm.hist[mTerm.histi] );
}
mTerm.hist[mTerm.histi] = (Line)eeMalloc( col * sizeof( TerminalGlyph ) );
memcpy( mTerm.hist[mTerm.histi], line, col * sizeof( TerminalGlyph ) );
Line* slot = &mTerm.hist[mTerm.histi];
/* All slots are (re)allocated together whenever the terminal width changes
* (see tresize/historyReflow), so a single shared capacity is enough to know
* when a slot can be recycled in place. This avoids a malloc+free pair for
* every pushed line, which is the hot path when output scrolls quickly. */
if ( !*slot || mTerm.histcapacity < col ) {
eeSAFE_FREE( *slot );
mTerm.histcapacity = col;
*slot = (Line)eeMalloc( col * sizeof( TerminalGlyph ) );
}
memcpy( *slot, line, col * sizeof( TerminalGlyph ) );
}
void TerminalEmulator::historyStealPush( Line* lineSlot, int col ) {
if ( mTerm.histsize <= 0 )
return;
/* Inspect the line before its ownership moves into the history ring. */
historyUpdateMaxWidth( *lineSlot, col );
mTerm.histi = ( mTerm.histi + 1 ) % mTerm.histsize;
if ( mTerm.histlen < mTerm.histsize )
mTerm.histlen++;
if ( mTerm.histi >= (int)mTerm.histcursize ) {
int newSize = eemin( mTerm.histi + mTerm.row, mTerm.histsize );
mTerm.hist = (Line*)xrealloc( mTerm.hist, newSize * sizeof( Line ) );
for ( int i = mTerm.histcursize; i < newSize; i++ )
mTerm.hist[i] = nullptr;
mTerm.histcursize = newSize;
}
/* Zero-copy scroll: the screen row's buffer becomes the history entry and
* the evicted history buffer becomes the screen row. Ownership trades
* places; no bytes move. tscrollup() blanks the returned buffer right
* after through tclearregion(), so no clearing happens here. This keeps a
* single write pass per scrolled row instead of a copy plus a clear. */
Line* slot = &mTerm.hist[mTerm.histi];
Line recycled = *slot;
*slot = *lineSlot;
*lineSlot = recycled;
if ( !recycled || mTerm.histcapacity < col ) {
/* Fresh ring growth (null slot) or an undersized leftover buffer: the
* screen row gets a brand-new buffer instead. */
eeSAFE_FREE( recycled );
*lineSlot = (Line)eeMalloc( col * sizeof( TerminalGlyph ) );
mTerm.histcapacity = col;
}
}
void TerminalEmulator::historyReflow( int old_col, int new_col ) {
@@ -1087,7 +1240,6 @@ void TerminalEmulator::historyReflow( int old_col, int new_col ) {
if ( is_wrapped )
continue;
while ( logical_len > 0 ) {
TerminalGlyph* g = &logical[logical_len - 1];
if ( g->u == ' ' && g->bg == mDefaultBg && ( g->mode & ATTR_BOLD ) == 0 )
@@ -1107,7 +1259,17 @@ void TerminalEmulator::historyReflow( int old_col, int new_col ) {
int cursor = 0;
while ( cursor < logical_len ) {
Line nl = (Line)eeMalloc( new_col * sizeof( TerminalGlyph ) );
Line nl;
if ( new_len < mTerm.histsize ) {
/* Fresh slot: allocate a new line buffer. */
nl = (Line)eeMalloc( new_col * sizeof( TerminalGlyph ) );
} else {
/* Destination ring already holds histsize lines: detach the oldest
* one and reuse its buffer instead of free+malloc. */
int next = ( new_histi + 1 ) % mTerm.histsize;
nl = new_hist[next];
new_hist[next] = nullptr;
}
for ( j = 0; j < new_col; j++ ) {
nl[j] = mTerm.c.attr;
nl[j].u = ' ';
@@ -1151,12 +1313,21 @@ void TerminalEmulator::historyReflow( int old_col, int new_col ) {
else
nl[new_col - 1].mode &= ~ATTR_WRAP;
/* Clear the rest of a reused buffer so no stale glyphs from the previous
* reflow pass remain visible beyond the copied content. */
for ( j = copy_width; j < new_col; j++ ) {
nl[j] = mTerm.c.attr;
nl[j].u = ' ';
nl[j].mode = 0;
}
new_histi = ( new_histi + 1 ) % mTerm.histsize;
if ( new_len < mTerm.histsize ) {
new_len++;
} else {
eeSAFE_FREE( new_hist[new_histi] );
}
/* When the ring is full the buffer ownership was already resolved above
* (oldest line detached and reused, or freshly allocated), so there is
* nothing left to free here. */
new_hist[new_histi] = nl;
int current_width = ( cursor + copy_width < logical_len ) ? new_col : copy_width;
@@ -1180,6 +1351,7 @@ void TerminalEmulator::historyReflow( int old_col, int new_col ) {
mTerm.histlen = new_len;
mTerm.histi = ( new_histi == -1 ) ? 0 : new_histi;
mTerm.max_width = new_max_width;
mTerm.histcapacity = new_col;
}
void TerminalEmulator::historyPopToScreen( int loaded, int col ) {
@@ -1195,6 +1367,11 @@ void TerminalEmulator::historyPopToScreen( int loaded, int col ) {
}
mTerm.histi = ( mTerm.histi - loaded + mTerm.histsize ) % mTerm.histsize;
mTerm.histlen -= loaded;
if ( mTerm.histlen == 0 ) {
mTerm.histcursize = 0;
mTerm.histcapacity = 0;
eeSAFE_FREE( mTerm.hist );
}
}
void TerminalEmulator::selmove( int n ) {
@@ -1339,6 +1516,27 @@ void TerminalEmulator::tclearregion( int x1, int y1, int x2, int y2, bool skip_c
LIMIT( y1, 0, mTerm.row - 1 );
LIMIT( y2, 0, mTerm.row - 1 );
/*
* Fast path for the common full-row clear performed while scrolling: no
* selection can be affected and every cleared cell gets the exact same
* glyph, so fill complete glyph objects instead of touching four scattered
* members per cell and running a per-cell selected() check.
*/
if ( skip_clear && mSel.ob.x == -1 && x1 == 0 && x2 == mTerm.col - 1 ) {
static_assert( sizeof( TerminalGlyph ) == 16,
"tclearregion fast path expects 16-byte glyphs" );
TerminalGlyph blank = mTerm.c.attr;
blank.mode = 0;
blank.u = ' ';
for ( y = y1; y <= y2; y++ ) {
gp = TLINE( y );
mTerm.dirty[y] = 1;
std::fill_n( gp, mTerm.col, blank );
mDirty = true;
}
return;
}
for ( y = y1; y <= y2; y++ ) {
mTerm.dirty[y] = 1;
mDirty = true;
@@ -2588,6 +2786,35 @@ void TerminalEmulator::tputc( Rune u ) {
size_t len;
TerminalGlyph* gp;
/*
* Fast path for plain printable ASCII while idle (no escape sequence in
* progress, no special print/insert mode): the checks below are all
* invariant for this class of input, so the glyph is written and the
* cursor advanced directly. This is the hot loop when large amounts of
* text stream through the terminal.
*/
if ( u >= ' ' && u < 127 && !mTerm.esc && !( mTerm.c.state & CURSOR_WRAPNEXT ) &&
!IS_SET( MODE_PRINT ) && !IS_SET( MODE_INSERT ) && mTerm.c.x + 1 < mTerm.col &&
mTerm.c.x < mTerm.col && mTerm.c.y < mTerm.row &&
mTerm.trantbl[mTerm.charset] != CS_GRAPHIC0 ) {
gp = &TLINE( mTerm.c.y )[mTerm.c.x];
/* tsetchar() repairs both halves when overwriting a wide glyph. Keep that
* uncommon case on the complete path. */
if ( !( gp->mode & ( ATTR_WIDE | ATTR_WDUMMY ) ) ) {
if ( selected( mTerm.c.x, mTerm.c.y ) )
selclear();
gp->u = u;
gp->mode = mTerm.c.attr.mode;
gp->fg = mTerm.c.attr.fg;
gp->bg = mTerm.c.attr.bg;
mDirty = true;
mTerm.dirty[mTerm.c.y] = 1;
mTerm.lastc = u;
mTerm.c.x++;
return;
}
}
control = ISCONTROL( u );
if ( u < 127 || !IS_SET( MODE_UTF8 ) ) {
c[0] = u;
@@ -2859,6 +3086,7 @@ void TerminalEmulator::tresize( int col, int row ) {
}
}
}
mTerm.histcapacity = eemax( mTerm.histcapacity, col );
if ( has_sel ) {
if ( mSel.ob.x >= col )
mSel.ob.x = col - 1;
@@ -2872,6 +3100,7 @@ void TerminalEmulator::tresize( int col, int row ) {
eeSAFE_FREE( mTerm.line[i] );
eeSAFE_FREE( mTerm.alt[i] );
}
mAllDirty = true;
eeSAFE_FREE( mTerm.line );
eeSAFE_FREE( mTerm.alt );
eeSAFE_FREE( mTerm.dirty );
@@ -2968,6 +3197,7 @@ void TerminalEmulator::drawregion( ITerminalDisplay& dpy, int x1, int y1, int x2
}
mDirty = false;
mAllDirty = false;
}
void TerminalEmulator::draw() {
@@ -3002,6 +3232,8 @@ void TerminalEmulator::draw() {
mTerm.ocy = mTerm.c.y;
dpy->drawEnd();
mDeferredPresentationBatches = 0;
mPresentationClock.restart();
}
// if (ocx != term.ocx || ocy != term.ocy)
@@ -3364,22 +3596,40 @@ bool TerminalEmulator::update() {
return true;
}
int read = MAX_TTY_READS;
while ( ttyread() > 0 && --read )
;
int reads = 0;
Clock readBudgetClock;
while ( reads < MAX_TTY_READS && ttyread() > 0 ) {
++reads;
if ( readBudgetClock.getElapsedTime() >= Milliseconds( 4 ) )
break;
}
bool readBudgetSaturated =
reads == MAX_TTY_READS ||
( reads > 0 && readBudgetClock.getElapsedTime() >= Milliseconds( 4 ) );
if ( read != MAX_TTY_READS || mDirty )
/* Keep presentation decoupled from every PTY read batch, but bound the
* deferral so sustained output remains visibly live. The time limit handles
* expensive batches; the batch limit guarantees progress when updates are
* individually very fast. */
if ( readBudgetSaturated )
++mDeferredPresentationBatches;
bool presentationDue = !readBudgetSaturated || mDeferredPresentationBatches >= 32 ||
mPresentationClock.getElapsedTime() >= Milliseconds( 75 );
if ( presentationDue && ( reads > 0 || mDirty ) )
draw();
mProcess->checkExitStatus();
if ( mProcess->hasExited() ) {
/* A process may exit while the kernel still has unread PTY output. Keep the
* emulator running until a read slice reaches EOF; otherwise the time budget
* can truncate the final output of fast-exiting producers. */
if ( mProcess->hasExited() && !readBudgetSaturated ) {
mExitCode = mProcess->getExitCode();
mStatus = TERMINATED;
onProcessExit( mExitCode );
}
return read != 0;
return !readBudgetSaturated;
}
Term::~Term() {

View File

@@ -3,6 +3,7 @@
#include <eterm/terminal/ipseudoterminal.hpp>
#include <eterm/terminal/iterminaldisplay.hpp>
#include <eterm/terminal/terminalemulator.hpp>
#include <limits>
using namespace eterm::Terminal;
using namespace eterm::System;
@@ -10,6 +11,7 @@ using namespace eterm::System;
class MockPty : public IPseudoTerminal {
public:
std::string mBuffer;
size_t mMaxRead{ std::numeric_limits<size_t>::max() };
int mCols = 80;
int mRows = 24;
int getNumColumns() const override { return mCols; }
@@ -27,7 +29,7 @@ class MockPty : public IPseudoTerminal {
int read( char* buf, size_t n, bool ) override {
if ( mBuffer.empty() )
return 0;
size_t toRead = std::min( n, mBuffer.size() );
size_t toRead = std::min( { n, mBuffer.size(), mMaxRead } );
memcpy( buf, mBuffer.data(), toRead );
mBuffer.erase( 0, toRead );
return toRead;
@@ -36,8 +38,9 @@ class MockPty : public IPseudoTerminal {
class MockProcess : public IProcess {
public:
bool mExited{ false };
void checkExitStatus() override {}
bool hasExited() const override { return false; }
bool hasExited() const override { return mExited; }
int getExitCode() const override { return 0; }
void terminate() override {}
void waitForExit() override {}
@@ -46,8 +49,15 @@ class MockProcess : public IProcess {
class MockDisplay : public ITerminalDisplay {
public:
int mDrawLines{ 0 };
uint32_t mFirstMode{ 0 };
uint32_t mSecondMode{ 0 };
bool drawBegin( Uint32, Uint32 ) override { return true; }
void drawLine( Line, int, int, int ) override {}
void drawLine( Line line, int, int, int ) override {
++mDrawLines;
mFirstMode = line[0].mode;
mSecondMode = line[1].mode;
}
void drawCursor( int, int, TerminalGlyph, int, int, TerminalGlyph ) override {}
void drawEnd() override {}
};
@@ -67,6 +77,133 @@ UTEST( eterm, basic_write ) {
EXPECT_STDSTREQ( "ABC", term->getSelection() );
}
UTEST( eterm, selection_redraw_while_idle ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
term->write( "ABC", 3 );
term->update();
display->mDrawLines = 0;
term->selstart( 0, 0, 0 );
term->selextend( 2, 0, 1, 0 );
term->update();
EXPECT_TRUE( display->mDrawLines > 0 );
}
UTEST( eterm, saturated_read_defers_only_intermediate_draw ) {
auto pty = std::make_unique<MockPty>();
pty->mMaxRead = 1;
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
std::string output( 1024, 'A' );
term->write( output.data(), output.size() );
display->mDrawLines = 0;
EXPECT_FALSE( term->update() );
EXPECT_EQ( 0, display->mDrawLines );
EXPECT_TRUE( term->update() );
EXPECT_TRUE( display->mDrawLines > 0 );
}
UTEST( eterm, sustained_saturated_reads_present_periodically ) {
auto pty = std::make_unique<MockPty>();
pty->mMaxRead = 1;
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
term->update();
display->mDrawLines = 0;
std::string output( 33 * 1024, 'A' );
term->write( output.data(), output.size() );
for ( int batch = 0; batch < 31; ++batch ) {
EXPECT_FALSE( term->update() );
EXPECT_EQ( 0, display->mDrawLines );
}
EXPECT_FALSE( term->update() );
EXPECT_TRUE( display->mDrawLines > 0 );
}
UTEST( eterm, process_exit_drains_buffered_pty_output ) {
auto pty = std::make_unique<MockPty>();
pty->mMaxRead = 1;
auto process = std::make_unique<MockProcess>();
process->mExited = true;
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
size_t bytesRead = 0;
term->setDataCb( [&bytesRead]( const char*, size_t size ) { bytesRead += size; } );
std::string output( 3 * 1024, 'A' );
term->write( output.data(), output.size() );
display->mDrawLines = 0;
EXPECT_FALSE( term->update() );
EXPECT_EQ( static_cast<size_t>( 1024 ), bytesRead );
while ( !term->update() ) {
}
EXPECT_EQ( output.size(), bytesRead );
EXPECT_TRUE( display->mDrawLines > 0 );
}
UTEST( eterm, repeated_deferred_scrolls_redraw_every_row ) {
auto pty = std::make_unique<MockPty>();
MockPty* mockPty = pty.get();
pty->mMaxRead = 1;
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
// Consume the initial full redraw so this burst starts with clean dirty flags.
term->update();
display->mDrawLines = 0;
for ( int line = 0; line < 200; ++line )
mockPty->mBuffer += "scrolling line " + std::to_string( line ) + "\r\n";
EXPECT_FALSE( term->update() );
EXPECT_EQ( 0, display->mDrawLines );
while ( !term->update() ) {
}
EXPECT_TRUE( display->mDrawLines >= 24 );
EXPECT_EQ( 0, display->mDrawLines % 24 );
}
UTEST( eterm, ascii_overwrite_clears_wide_glyph_state ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
term->write( "\xF0\x9F\x9A\x80\rA", 6 );
term->update();
EXPECT_EQ( 0u, display->mFirstMode & ATTR_WIDE );
EXPECT_EQ( 0u, display->mSecondMode & ATTR_WDUMMY );
}
UTEST( eterm, ascii_respects_vt100_graphics_charset ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 );
term->write( "\033(0q", 4 );
term->update();
term->selstart( 0, 0, 0 );
term->selextend( 0, 0, 1, 0 );
EXPECT_STDSTREQ( "", term->getSelection() );
}
UTEST( eterm, selection_reflow ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();