From 2edf4cfd4378923901d801fe8acd8eca4fa50dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 17 Sep 2026 20:33:24 -0300 Subject: [PATCH] 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 --- .../eterm/terminal/terminaldisplay.hpp | 1 + .../eterm/terminal/terminalemulator.hpp | 3 + .../eterm/terminal/terminalsession.hpp | 25 ++- .../eterm/include/eterm/ui/uiterminal.hpp | 2 + .../src/eterm/terminal/terminaldisplay.cpp | 64 ++++--- .../src/eterm/terminal/terminalemulator.cpp | 64 +++++-- .../src/eterm/terminal/terminalsession.cpp | 16 +- src/modules/eterm/src/eterm/ui/uiterminal.cpp | 43 +++-- src/tests/unit_tests/eterm_tests.cpp | 166 +++++++++++++++++- 9 files changed, 312 insertions(+), 72 deletions(-) diff --git a/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp b/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp index 2b91870a6..e759a4b88 100644 --- a/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp @@ -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; diff --git a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp index 68eaa6232..9a97cd610 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp @@ -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(); diff --git a/src/modules/eterm/include/eterm/terminal/terminalsession.hpp b/src/modules/eterm/include/eterm/terminal/terminalsession.hpp index 02a8bba47..55f7a4132 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalsession.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalsession.hpp @@ -93,19 +93,18 @@ class TerminalSession final : public std::enable_shared_from_this; 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 { diff --git a/src/modules/eterm/include/eterm/ui/uiterminal.hpp b/src/modules/eterm/include/eterm/ui/uiterminal.hpp index ac2aede37..96c1570a1 100644 --- a/src/modules/eterm/include/eterm/ui/uiterminal.hpp +++ b/src/modules/eterm/include/eterm/ui/uiterminal.hpp @@ -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(); diff --git a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp index 8f9cf4448..2e57dedf8 100644 --- a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp @@ -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( mColumns ) || snapshot->rows != static_cast( 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& colors ) { diff --git a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp index a4b8bb6fa..d5f0a183b 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp @@ -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( 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*/; diff --git a/src/modules/eterm/src/eterm/terminal/terminalsession.cpp b/src/modules/eterm/src/eterm/terminal/terminalsession.cpp index 7a01899f4..13ac96f79 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalsession.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalsession.cpp @@ -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 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 ) { diff --git a/src/modules/eterm/src/eterm/ui/uiterminal.cpp b/src/modules/eterm/src/eterm/ui/uiterminal.cpp index f61fd0d68..96f59f8f3 100644 --- a/src/modules/eterm/src/eterm/ui/uiterminal.cpp +++ b/src/modules/eterm/src/eterm/ui/uiterminal.cpp @@ -93,7 +93,11 @@ UITerminal::UITerminal( const std::shared_ptr& 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; diff --git a/src/tests/unit_tests/eterm_tests.cpp b/src/tests/unit_tests/eterm_tests.cpp index 546d89649..9a641c47f 100644 --- a/src/tests/unit_tests/eterm_tests.cpp +++ b/src/tests/unit_tests/eterm_tests.cpp @@ -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(); + pty->mBuffer = "persistent selection"; + auto process = std::make_unique(); + 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(); auto process = std::make_unique(); @@ -421,6 +443,7 @@ class MockDisplay : public ITerminalDisplay { public: int mDrawLines{ 0 }; int mDrawEnds{ 0 }; + std::vector 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 presentation, std::vector ) 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(); + pty->mBuffer = "\033[>8u"; + MockPty* ptyPtr = pty.get(); + auto process = std::make_unique(); + auto display = std::make_shared(); + 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(); 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(); + auto process = std::make_unique(); + auto display = std::make_shared(); + 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(); auto process = std::make_unique(); @@ -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(); + auto process = std::make_unique(); + auto display = std::make_shared(); + 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(); + auto process = std::make_unique(); + auto display = std::make_shared(); + 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(); + auto process = std::make_unique(); + auto display = std::make_shared(); + 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(); auto process = std::make_unique();