Fix terminal scroll anFix terminal scroll and selection synchronization

eterm:
    - derive scroll and history notifications from adopted snapshots instead
      of delayed worker events
    - prevent PTY parsing from publishing its temporary live-buffer viewport
    - restore scrollback correctly when new output wraps the history ring
    - keep selections attached to their text during output and absolute scrolling
    - preserve terminal focus and selection while interacting with the scrollbar
    - keep the scrollbar available while a selection is active
    - suppress application mouse reports when Shift overrides mouse capture
    - preserve scrollback when Kitty reports standalone modifier keys
    - retain scroll-to-bottom behavior for regular and modified input keys
    - remove obsolete worker scroll and history event tracking
    - add regression coverage for viewport, selection, focus, keyboard, and
      history behaviord selection synchronization
This commit is contained in:
Martín Lucas Golini
2026-09-17 20:33:24 -03:00
parent 0898e20f2a
commit 2edf4cfd43
9 changed files with 312 additions and 72 deletions

View File

@@ -371,6 +371,7 @@ class TerminalDisplay {
bool mAlreadyClickedMButton{ false };
bool mKeepAlive{ true };
bool mDraggingSel{ false };
bool mSelectionOverridesMouseCapture{ false };
int mMode{ MODE_VISIBLE | MODE_FOCUSED };
TerminalCursorMode mCursorMode{ SteadyUnderline };
Clock mClock;

View File

@@ -326,6 +326,8 @@ class TerminalEmulator final {
bool mDirty{ true };
bool mAllDirty{ true };
bool mProcessingPtyInput{ false };
int mPtyHistoryLinesPushed{ 0 };
Clock mPresentationClock;
Time mPresentationInterval{ Microseconds( 1000000.0 / 60.0 ) };
Clock mSynchronizedUpdateClock;
@@ -394,6 +396,7 @@ class TerminalEmulator final {
void csihandle();
bool handleKittyKeyboardProtocol();
KittyKeyboardState& activeKeyboardState();
void ttywriteInternal( const char* s, size_t n, int may_echo, bool scrollToBottom );
void resetKittyKeyboardProtocol();
void csiparse();
void csireset();

View File

@@ -93,19 +93,18 @@ class TerminalSession final : public std::enable_shared_from_this<TerminalSessio
using ProcPtr = std::unique_ptr<IProcess>;
enum class EventType : Uint8 {
Title,
IconTitle,
HistoryLength,
ScrollPosition,
Bell,
Clipboard,
ProcessExit,
RestartFailure,
SnapshotReady,
Data,
PromptState,
Color,
Error
Title = 0,
IconTitle = 1,
// Values 2 and 3 were retired; keep later values stable for ABI compatibility.
Bell = 4,
Clipboard = 5,
ProcessExit = 6,
RestartFailure = 7,
SnapshotReady = 8,
Data = 9,
PromptState = 10,
Color = 11,
Error = 12
};
struct Event {

View File

@@ -170,8 +170,10 @@ class UITerminal : public UITouchDraggableWidget {
virtual Uint32 onFocus( NodeFocusReason reason );
virtual Uint32 onFocusLoss();
virtual Uint32 onMessage( const NodeMessage* msg );
virtual void updateScroll();
void syncScrollOffset();
virtual void onContentSizeChange();

View File

@@ -770,6 +770,7 @@ bool TerminalDisplay::update( bool isMouseOverMe ) {
if ( !( mWindow->getInput()->getPressTrigger() & EE_BUTTON_LMASK ) ) {
mWindow->getInput()->captureMouse( false );
mDraggingSel = false;
mSelectionOverridesMouseCapture = false;
} else if ( !isMouseOverMe ) {
onMouseMove( mWindow->getInput()->getMousePos(),
mWindow->getInput()->getPressTrigger() );
@@ -809,6 +810,8 @@ void TerminalDisplay::consumeSnapshot() {
return;
const Vector2i previousCursor = mCursor;
const int previousHistoryLength = mSnapshot ? mSnapshot->historyLength : 0;
const int previousScrollPosition = mSnapshot ? mSnapshot->scrollPosition : 0;
const bool dimensionsChanged = snapshot->columns != static_cast<int>( mColumns ) ||
snapshot->rows != static_cast<int>( mRows );
if ( dimensionsChanged ) {
@@ -857,6 +860,13 @@ void TerminalDisplay::consumeSnapshot() {
mMode |= MODE_BLINK;
mClock.restart();
}
// Scrollbar state must be announced only after the immutable state it describes has been
// adopted. Worker events can otherwise race publication and make the UI feed an older absolute
// position back into the session.
if ( previousScrollPosition != mSnapshot->scrollPosition )
sendEvent( { EventType::SCROLL_HISTORY } );
if ( previousHistoryLength != mSnapshot->historyLength )
sendEvent( { EventType::HISTORY_LENGTH_CHANGE } );
mDirty = true;
}
@@ -871,9 +881,6 @@ void TerminalDisplay::drainSessionEvents() {
case TerminalSession::EventType::IconTitle:
sendEvent( { EventType::ICON_TITLE, std::move( event.data ) } );
break;
case TerminalSession::EventType::ScrollPosition:
sendEvent( { EventType::SCROLL_HISTORY } );
break;
case TerminalSession::EventType::Bell:
sendEvent( { EventType::BELL } );
break;
@@ -906,9 +913,6 @@ void TerminalDisplay::drainSessionEvents() {
Log::error( "Terminal worker error: %s", event.data.c_str() );
sendEvent( { EventType::WORKER_ERROR, std::move( event.data ) } );
break;
case TerminalSession::EventType::HistoryLength:
sendEvent( { EventType::HISTORY_LENGTH_CHANGE } );
break;
case TerminalSession::EventType::SnapshotReady:
break;
}
@@ -1139,9 +1143,13 @@ void TerminalDisplay::onMouseDoubleClick( const Vector2i& pos, const Uint32& fla
}
void TerminalDisplay::onMouseMove( const Vector2i& pos, const Uint32& flags ) {
bool shiftPressed = ( mWindow->getInput()->getModState() & KEYMOD_SHIFT ) != 0;
const Uint32 modifiers = mWindow->getInput()->getModState();
const bool shiftPressed = ( modifiers & KEYMOD_SHIFT ) != 0;
auto mousePos = mWindow->getInput()->getRelativeMousePos();
bool isCapturingMouse = isAppCapturingMouse() && !shiftPressed;
const bool appCapturingMouse = isAppCapturingMouse();
const bool selectionOverride =
mSelectionOverridesMouseCapture || ( appCapturingMouse && shiftPressed );
const bool isCapturingMouse = appCapturingMouse && !selectionOverride;
if ( !isAltScr() && !isCapturingMouse && ( flags & EE_BUTTON_LMASK ) &&
mAlreadyClickedLButton ) {
@@ -1163,17 +1171,22 @@ void TerminalDisplay::onMouseMove( const Vector2i& pos, const Uint32& flags ) {
( mDraggingSel || getSelectionMode() == SEL_EMPTY || getSelectionMode() == SEL_READY ) ) {
auto gridPos{ positionToGrid( pos ) };
mSession->selectionExtend(
gridPos.x, gridPos.y,
mWindow->getInput()->getModState() & KEYMOD_SHIFT ? SEL_RECTANGULAR : SEL_REGULAR,
false );
gridPos.x, gridPos.y, modifiers & KEYMOD_SHIFT ? SEL_RECTANGULAR : SEL_REGULAR, false );
}
// Shift overrides application mouse capture so the user can select terminal text. Sending the
// same event to the application would make the override ineffective.
if ( !selectionOverride ) {
mSession->mouseReport( TerminalMouseEventType::MouseMotion, positionToGrid( pos ),
positionToPixel( pos ), flags, modifiers );
}
mSession->mouseReport( TerminalMouseEventType::MouseMotion, positionToGrid( pos ),
positionToPixel( pos ), flags, mWindow->getInput()->getModState() );
}
void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
bool shiftPressed = ( mWindow->getInput()->getModState() & KEYMOD_SHIFT ) != 0;
bool isCapturingMouse = isAppCapturingMouse() && !shiftPressed;
const Uint32 modifiers = mWindow->getInput()->getModState();
const bool shiftPressed = ( modifiers & KEYMOD_SHIFT ) != 0;
const bool appCapturingMouse = isAppCapturingMouse();
const bool selectionOverride = appCapturingMouse && shiftPressed;
const bool isCapturingMouse = appCapturingMouse && !selectionOverride;
if ( ( flags & EE_BUTTON_LMASK ) && mDraggingSel )
return;
@@ -1187,6 +1200,7 @@ void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
if ( !mDraggingSel ) {
mSession->selectionStart( gridPos.x, gridPos.y, 0 );
mDraggingSel = true;
mSelectionOverridesMouseCapture = selectionOverride;
invalidateLines();
mWindow->getInput()->captureMouse( true );
}
@@ -1208,11 +1222,18 @@ void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
}
}
mSession->mouseReport( TerminalMouseEventType::MouseButtonDown, positionToGrid( pos ),
positionToPixel( pos ), flags, mWindow->getInput()->getModState() );
if ( !selectionOverride ) {
mSession->mouseReport( TerminalMouseEventType::MouseButtonDown, positionToGrid( pos ),
positionToPixel( pos ), flags, modifiers );
}
}
void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) {
const Uint32 modifiers = mWindow->getInput()->getModState();
const bool shiftPressed = ( modifiers & KEYMOD_SHIFT ) != 0;
const bool appCapturingMouse = isAppCapturingMouse();
const bool selectionOverride =
mSelectionOverridesMouseCapture || ( appCapturingMouse && shiftPressed );
if ( ( flags & EE_BUTTON_LMASK ) && mDraggingSel ) {
mDraggingSel = false;
}
@@ -1221,11 +1242,12 @@ void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) {
mWindow->getClipboard()->setPrimarySelectionText( getSelection() );
}
Uint32 smod = sanitizeMod( mWindow->getInput()->getModState() );
Uint32 smod = sanitizeMod( modifiers );
if ( flags & EE_BUTTON_LMASK ) {
mAlreadyClickedLButton = false;
mWindow->getInput()->captureMouse( false );
mSelectionOverridesMouseCapture = false;
}
if ( flags & EE_BUTTON_MMASK )
@@ -1255,8 +1277,10 @@ void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) {
}
}
mSession->mouseReport( TerminalMouseEventType::MouseButtonRelease, positionToGrid( pos ),
positionToPixel( pos ), flags, mWindow->getInput()->getModState() );
if ( !selectionOverride ) {
mSession->mouseReport( TerminalMouseEventType::MouseButtonRelease, positionToGrid( pos ),
positionToPixel( pos ), flags, modifiers );
}
}
static inline Color termColor( unsigned int terminalColor, const std::vector<Color>& colors ) {

View File

@@ -947,28 +947,33 @@ size_t TerminalEmulator::ttyread( void ) {
mDataCb( mBuf + mBuflen, ret );
int old_scr = mTerm.scr;
int old_histi = mTerm.histi;
// Selection coordinates share the viewport's coordinate space. Translate them before
// the temporary live-screen switch so parser writes cannot mistake historical text for
// a live cell and clear the selection.
if ( old_scr > 0 )
selmove( -old_scr );
mTerm.scr = 0;
mBuflen += ret;
// Parsing must update the live screen at scr == 0, but that temporary viewport is not
// presentation state. In particular, DECRST 2026 can call draw() from inside twrite().
// Publishing there lets the asynchronous UI mistake the live-screen override for a user
// scroll and feed it back through the scrollbar before the viewport is restored below.
mPtyHistoryLinesPushed = 0;
mProcessingPtyInput = true;
written = twrite( mBuf, mBuflen, 0 );
mProcessingPtyInput = false;
mBuflen -= written;
/* keep any incomplete UTF-8 byte sequence for the next call */
if ( mBuflen > 0 )
memmove( mBuf, mBuf + written, mBuflen );
if ( old_scr > 0 ) {
int lines_pushed = 0;
if ( mTerm.histsize > 0 ) {
lines_pushed = ( mTerm.histi - old_histi + mTerm.histsize ) % mTerm.histsize;
}
const int lines_pushed = mPtyHistoryLinesPushed;
mTerm.scr = eemin( mTerm.histlen, old_scr + lines_pushed );
if ( lines_pushed > 0 ) {
mSel.ob.y += lines_pushed;
mSel.oe.y += lines_pushed;
selmove( mTerm.scr );
if ( mTerm.scr != old_scr )
onScrollPositionChange();
}
}
return ret;
@@ -1025,7 +1030,9 @@ void TerminalEmulator::kscrollto( const TerminalArg* a ) {
int n = a->i;
if ( 0 <= n && n <= mTerm.histlen ) {
int delta = n - mTerm.scr;
mTerm.scr = n;
selmove( delta );
tfulldirt();
onScrollPositionChange();
}
@@ -1117,10 +1124,17 @@ bool TerminalEmulator::isScrolling() const {
}
void TerminalEmulator::ttywrite( const char* s, size_t n, int may_echo ) {
ttywriteInternal( s, n, may_echo, true );
}
void TerminalEmulator::ttywriteInternal( const char* s, size_t n, int may_echo,
bool scrollToBottom ) {
const char* next;
TerminalArg arg = { (int)mTerm.scr };
kscrolldown( &arg );
if ( scrollToBottom ) {
TerminalArg arg = { (int)mTerm.scr };
kscrolldown( &arg );
}
if ( may_echo && IS_SET( MODE_ECHO ) )
twrite( s, (int)n, 1 );
@@ -1149,6 +1163,22 @@ static Uint32 keyboardSanitizeMod( Uint32 mod ) {
return mod & KEYMOD_CTRL_SHIFT_ALT_META;
}
static bool isModifierKey( Keycode keycode ) {
switch ( keycode ) {
case KEY_LCTRL:
case KEY_LSHIFT:
case KEY_LALT:
case KEY_LGUI:
case KEY_RCTRL:
case KEY_RSHIFT:
case KEY_RALT:
case KEY_RGUI:
return true;
default:
return false;
}
}
static char legacyControlCharacter( Scancode scancode ) {
if ( scancode >= SCANCODE_A && scancode <= SCANCODE_Z )
return static_cast<char>( scancode - SCANCODE_A + 1 );
@@ -1181,7 +1211,8 @@ void TerminalEmulator::keyEvent( const KittyKeyEvent& event ) {
}
const auto enhanced = KittyKeyboardEncoder::encode( event, flags );
if ( enhanced.handled ) {
ttywrite( enhanced.bytes.data(), enhanced.bytes.size(), 1 );
ttywriteInternal( enhanced.bytes.data(), enhanced.bytes.size(), 1,
!isModifierKey( event.keycode ) );
mExpectedTextInput = enhanced.expectedText;
return;
}
@@ -1470,6 +1501,8 @@ void TerminalEmulator::tscrollup( int top, int n, int copyhist ) {
for ( i = 0; i < n; i++ )
historyStealPush( &mTerm.line[top + i], mTerm.col );
if ( mProcessingPtyInput )
mPtyHistoryLinesPushed += n;
if ( attop )
mTerm.scr = mTerm.histlen;
@@ -3874,8 +3907,9 @@ void TerminalEmulator::drawregion( ITerminalDisplay& dpy, int x1, int y1, int x2
void TerminalEmulator::draw() {
// DEC private mode 2026 makes the bytes between DECSET and DECRST one presentation unit.
// Parsing continues normally, but no partially cleared/rebuilt frame may reach the UI.
if ( mTerm.is_syncing )
// PTY parsing can also temporarily force scr to zero while updating the live screen. Neither
// state is a stable presentation boundary, so no partial frame or viewport may reach the UI.
if ( mTerm.is_syncing || mProcessingPtyInput )
return;
int cx = mTerm.c.x /*, ocx = term.ocx, ocy = term.ocy*/;

View File

@@ -135,12 +135,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
if ( auto* process = mEmulator->getProcess() )
snapshot->processId = process->pid();
}
if ( snapshot->historyLength != mLastHistoryLength ) {
mLastHistoryLength = snapshot->historyLength;
Event event{ EventType::HistoryLength };
event.value = mLastHistoryLength;
mSession.enqueueEvent( std::move( event ), true );
}
mSession.publishSnapshot( std::move( snapshot ) );
}
@@ -239,8 +233,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
mSession.enqueueEvent( std::move( event ), false );
}
void onScrollPositionChange() { mSession.enqueueEvent( { EventType::ScrollPosition }, true ); }
void setPalette( TerminalColorPalette palette ) {
mInitialPalette = palette;
mPalette = std::move( palette );
@@ -284,7 +276,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
TerminalGlyph mCursorGlyph;
int mColumns{ 0 };
int mRows{ 0 };
int mLastHistoryLength{ -1 };
Uint32 mPresentationRate{ 60 };
bool mCursorVisible{ false };
};
@@ -498,10 +489,9 @@ void TerminalSession::enqueueEvent( Event event, bool coalescable ) {
std::lock_guard<std::mutex> lock( mEventMutex );
if ( coalescable ) {
for ( auto it = mEvents.rbegin(); it != mEvents.rend(); ++it ) {
const bool replaceable =
it->type == EventType::Title || it->type == EventType::IconTitle ||
it->type == EventType::HistoryLength || it->type == EventType::ScrollPosition ||
it->type == EventType::SnapshotReady;
const bool replaceable = it->type == EventType::Title ||
it->type == EventType::IconTitle ||
it->type == EventType::SnapshotReady;
if ( !replaceable )
break;
if ( it->type == event.type ) {

View File

@@ -93,7 +93,11 @@ UITerminal::UITerminal( const std::shared_ptr<TerminalDisplay>& terminalDisplay
syncFontRenderingConfig();
registerNewTerminal();
mVScroll->setParent( this );
mVScroll->on( Event::OnValueChange, [this]( const Event* ) { updateScroll(); } );
mVScroll->on( Event::OnValueChange, [this]( const Event* ) {
updateScroll();
if ( !mApplyingScrollController )
stopScrollController();
} );
setCommand( "terminal-scroll-up-screen",
[this] { mTerm->action( TerminalShortcutAction::SCROLLUP_SCREEN ); } );
@@ -162,7 +166,9 @@ void UITerminal::onContentSizeChange() {
mPendingContentSizeChange = false;
updateScrollPosition();
mVScroll->setPageStep( contentSize > 0 ? ( visibleArea / (Float)contentSize ) : 1.f );
updateScroll();
// This is a worker-to-UI state synchronization, not a user scroll. Feeding it back through
// onScrollChange() queues a stale absolute position if the worker advances in the meantime.
syncScrollOffset();
}
const ScrollBarMode& UITerminal::getVerticalScrollMode() const {
@@ -225,15 +231,19 @@ int UITerminal::getScrollableArea() const {
}
void UITerminal::updateScroll() {
int totalScroll = getScrollableArea();
int initScroll( mScrollOffset );
syncScrollOffset();
if ( initScroll != mScrollOffset )
onScrollChange();
}
void UITerminal::syncScrollOffset() {
int totalScroll = getScrollableArea();
mScrollOffset = 0;
if ( mVScroll->isVisible() && totalScroll > 0 )
mScrollOffset = totalScroll * mVScroll->getValue();
if ( initScroll != mScrollOffset )
onScrollChange();
}
void UITerminal::onScrollChange() {
@@ -531,8 +541,7 @@ Uint32 UITerminal::onKeyUp( const KeyEvent& event ) {
Uint32 UITerminal::onMouseMove( const Vector2i& position, const Uint32& flags ) {
if ( mViewType == ScrollViewType::Overlay && ScrollBarMode::Auto == mVScrollMode ) {
mMouseClock.restart();
bool visible =
!mTerm->isAltScr() && getContentSize() > getVisibleArea() && !mTerm->hasSelection();
bool visible = !mTerm->isAltScr() && getContentSize() > getVisibleArea();
mVScroll->setVisible( visible )->setEnabled( visible );
}
@@ -647,12 +656,26 @@ Uint32 UITerminal::onFocus( NodeFocusReason reason ) {
Uint32 UITerminal::onFocusLoss() {
getUISceneNode()->getWindow()->stopTextInput();
mTerm->clearSuppressedKeys();
mTerm->setFocus( false );
Node* focusNode = getEventDispatcher()->getFocusNode();
const bool scrollBarFocus = focusNode == mVScroll || mVScroll->isParentOf( focusNode );
if ( !scrollBarFocus ) {
mTerm->clearSuppressedKeys();
mTerm->setFocus( false );
}
invalidateDraw();
return UIWidget::onFocusLoss();
}
Uint32 UITerminal::onMessage( const NodeMessage* msg ) {
if ( msg->getMsg() == NodeMessage::Focus &&
( msg->getSender() == mVScroll || mVScroll->isParentOf( msg->getSender() ) ) ) {
// The scrollbar is part of the terminal. Keep keyboard/PTY focus on the terminal instead
// of reporting a transient focus-out/focus-in pair to the application.
setFocus();
}
return UITouchDraggableWidget::onMessage( msg );
}
void UITerminal::createDefaultContextMenuOptions( UIPopUpMenu* menu ) {
if ( !mCreateDefaultContextMenuOptions )
return;

View File

@@ -293,6 +293,28 @@ UTEST( eterm_session, focus_reporting_is_ordered_on_worker ) {
EXPECT_STDSTREQ( "\033[I", ptyPtr->mWrites.substr( ptyPtr->mWrites.size() - 3 ) );
}
UTEST( eterm_session, focus_commands_do_not_clear_selection ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "persistent selection";
auto process = std::make_unique<MockProcess>();
auto session = TerminalSession::create( std::move( pty ), std::move( process ), 100 );
ASSERT_TRUE( waitForSnapshot( session, []( const TerminalSnapshot& snapshot ) {
return !snapshot.cells.empty() && snapshot.cells[0].u == 'p';
} ) != nullptr );
session->selectionStart( 0, 0, 0 );
session->selectionExtend( 9, 0, SEL_REGULAR, false );
auto selected = session->requestSelection();
ASSERT_TRUE( selected.has_value() );
ASSERT_STDSTREQ( "persistent", *selected );
session->setFocus( false );
session->setFocus( true );
auto afterFocusChange = session->requestSelection();
ASSERT_TRUE( afterFocusChange.has_value() );
EXPECT_STDSTREQ( "persistent", *afterFocusChange );
}
UTEST( eterm_session, replaceable_events_coalesce_without_losing_ordered_events ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
@@ -421,6 +443,7 @@ class MockDisplay : public ITerminalDisplay {
public:
int mDrawLines{ 0 };
int mDrawEnds{ 0 };
std::vector<int> mPublishedScrollPositions;
uint32_t mFirstMode{ 0 };
uint32_t mSecondMode{ 0 };
TerminalGlyph mFirstGlyph;
@@ -440,7 +463,11 @@ class MockDisplay : public ITerminalDisplay {
}
}
void drawCursor( int, int, TerminalGlyph, int, int, TerminalGlyph ) override {}
void drawEnd() override { ++mDrawEnds; }
void drawEnd() override {
++mDrawEnds;
if ( mEmulator )
mPublishedScrollPositions.emplace_back( mEmulator->scrollPos() );
}
void resetColors() override { ++mResetColorsCount; }
void drawGraphics( std::shared_ptr<TerminalGraphicsPresentation> presentation,
std::vector<TerminalGraphicsUpdate> ) override {
@@ -1048,6 +1075,34 @@ UTEST( eterm, kitty_keyboard_protocol_encodes_worker_key_without_duplicate_text
EXPECT_STDSTREQ( "\033[97;1u\033[13;5u", ptyPtr->mWrites );
}
UTEST( eterm, kitty_modifier_key_does_not_scroll_to_bottom ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "\033[>8u";
MockPty* ptyPtr = pty.get();
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();
for ( int i = 0; i < 40; ++i ) {
std::string line = "Line " + std::to_string( i ) + "\r\n";
term->write( line.c_str(), line.size() );
term->update();
}
ptyPtr->mLoopWrites = false;
TerminalArg scroll( 5 );
term->kscrollup( &scroll );
ptyPtr->mWrites.clear();
term->keyEvent( { KEY_LCTRL, SCANCODE_LCTRL, 0, KEYMOD_LCTRL, KittyKeyEventType::Press } );
EXPECT_FALSE( ptyPtr->mWrites.empty() );
EXPECT_EQ( 5, term->scrollPos() );
term->keyEvent( { KEY_A, SCANCODE_A, 0, KEYMOD_LCTRL, KittyKeyEventType::Press } );
EXPECT_EQ( 0, term->scrollPos() );
}
UTEST( eterm, kitty_keyboard_protocol_preserves_altgr_text ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "\033[>15u";
@@ -1464,6 +1519,33 @@ UTEST( eterm, synchronized_updates_publish_only_complete_frames ) {
EXPECT_STDSTREQ( "complete", term->getSelection() );
}
UTEST( eterm, pty_parsing_does_not_publish_temporary_bottom_viewport ) {
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 );
for ( int line = 0; line < 40; ++line ) {
const std::string output = "history " + std::to_string( line ) + "\r\n";
term->write( output.data(), output.size() );
term->update();
}
TerminalArg scroll( 5 );
term->kscrollup( &scroll );
ASSERT_EQ( 5, term->scrollPos() );
display->mPublishedScrollPositions.clear();
const char synchronizedOutput[] = "\033[?2026hnew output\r\n\033[?2026l";
term->write( synchronizedOutput, sizeof( synchronizedOutput ) - 1 );
term->update();
ASSERT_FALSE( display->mPublishedScrollPositions.empty() );
for ( int scrollPosition : display->mPublishedScrollPositions )
EXPECT_TRUE( scrollPosition > 0 );
EXPECT_EQ( 6, display->mPublishedScrollPositions.back() );
}
UTEST( eterm, sgr_colon_subparameters_preserve_groups_and_optional_color_space ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
@@ -2272,6 +2354,88 @@ UTEST( eterm, scroll_position_after_ttyread ) {
EXPECT_STDSTREQ( "New output", term->getSelection() );
}
UTEST( eterm, ttyread_keeps_scrolled_selection_attached_to_text ) {
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 );
for ( int i = 0; i < 40; ++i ) {
std::string line = "Line " + std::to_string( i ) + "\r\n";
term->write( line.c_str(), line.size() );
term->update();
}
TerminalArg scroll( 5 );
term->kscrollup( &scroll );
term->selstart( 0, 23, 0 );
term->selextend( 6, 23, SEL_REGULAR, false );
ASSERT_STDSTREQ( "Line 35", term->getSelection() );
term->write( "New output\r\n", 12 );
term->update();
EXPECT_EQ( 6, term->scrollPos() );
EXPECT_STDSTREQ( "Line 35", term->getSelection() );
}
UTEST( eterm, ttyread_restores_viewport_after_history_ring_wrap ) {
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, 4 );
for ( int i = 0; i < 28; ++i ) {
std::string line = "Line " + std::to_string( i ) + "\r\n";
term->write( line.c_str(), line.size() );
term->update();
}
TerminalArg scroll( 2 );
term->kscrollup( &scroll );
ASSERT_EQ( 2, term->scrollPos() );
// One PTY read pushes exactly histsize rows, wrapping histi back to its original index.
// The viewport must still account for all four pushed rows and clamp to the oldest history.
const char burst[] = "Burst 0\r\nBurst 1\r\nBurst 2\r\nBurst 3\r\n";
term->write( burst, sizeof( burst ) - 1 );
term->update();
EXPECT_EQ( 4, term->scrollPos() );
TerminalArg bottom( INT_MAX );
term->kscrolldown( &bottom );
term->selstart( 0, 22, 0 );
term->selextend( 6, 22, SEL_REGULAR, false );
EXPECT_STDSTREQ( "Burst 3", term->getSelection() );
}
UTEST( eterm, absolute_scrolling_keeps_selection_attached_to_text ) {
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 );
for ( int i = 0; i < 40; ++i ) {
std::string line = "Line " + std::to_string( i ) + "\r\n";
term->write( line.c_str(), line.size() );
term->update();
}
TerminalArg scroll( 5 );
term->kscrollto( &scroll );
term->selstart( 0, 23, 0 );
term->selextend( 6, 23, SEL_REGULAR, false );
ASSERT_STDSTREQ( "Line 35", term->getSelection() );
scroll.i = 10;
term->kscrollto( &scroll );
EXPECT_STDSTREQ( "Line 35", term->getSelection() );
scroll.i = 2;
term->kscrollto( &scroll );
EXPECT_STDSTREQ( "Line 35", term->getSelection() );
}
UTEST( eterm, history_corruption_on_resize ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();