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
@@ -371,6 +371,7 @@ class TerminalDisplay {
bool mAlreadyClickedMButton{ false }; bool mAlreadyClickedMButton{ false };
bool mKeepAlive{ true }; bool mKeepAlive{ true };
bool mDraggingSel{ false }; bool mDraggingSel{ false };
bool mSelectionOverridesMouseCapture{ false };
int mMode{ MODE_VISIBLE | MODE_FOCUSED }; int mMode{ MODE_VISIBLE | MODE_FOCUSED };
TerminalCursorMode mCursorMode{ SteadyUnderline }; TerminalCursorMode mCursorMode{ SteadyUnderline };
Clock mClock; Clock mClock;
@@ -326,6 +326,8 @@ class TerminalEmulator final {
bool mDirty{ true }; bool mDirty{ true };
bool mAllDirty{ true }; bool mAllDirty{ true };
bool mProcessingPtyInput{ false };
int mPtyHistoryLinesPushed{ 0 };
Clock mPresentationClock; Clock mPresentationClock;
Time mPresentationInterval{ Microseconds( 1000000.0 / 60.0 ) }; Time mPresentationInterval{ Microseconds( 1000000.0 / 60.0 ) };
Clock mSynchronizedUpdateClock; Clock mSynchronizedUpdateClock;
@@ -394,6 +396,7 @@ class TerminalEmulator final {
void csihandle(); void csihandle();
bool handleKittyKeyboardProtocol(); bool handleKittyKeyboardProtocol();
KittyKeyboardState& activeKeyboardState(); KittyKeyboardState& activeKeyboardState();
void ttywriteInternal( const char* s, size_t n, int may_echo, bool scrollToBottom );
void resetKittyKeyboardProtocol(); void resetKittyKeyboardProtocol();
void csiparse(); void csiparse();
void csireset(); void csireset();
@@ -93,19 +93,18 @@ class TerminalSession final : public std::enable_shared_from_this<TerminalSessio
using ProcPtr = std::unique_ptr<IProcess>; using ProcPtr = std::unique_ptr<IProcess>;
enum class EventType : Uint8 { enum class EventType : Uint8 {
Title, Title = 0,
IconTitle, IconTitle = 1,
HistoryLength, // Values 2 and 3 were retired; keep later values stable for ABI compatibility.
ScrollPosition, Bell = 4,
Bell, Clipboard = 5,
Clipboard, ProcessExit = 6,
ProcessExit, RestartFailure = 7,
RestartFailure, SnapshotReady = 8,
SnapshotReady, Data = 9,
Data, PromptState = 10,
PromptState, Color = 11,
Color, Error = 12
Error
}; };
struct Event { struct Event {
@@ -170,8 +170,10 @@ class UITerminal : public UITouchDraggableWidget {
virtual Uint32 onFocus( NodeFocusReason reason ); virtual Uint32 onFocus( NodeFocusReason reason );
virtual Uint32 onFocusLoss(); virtual Uint32 onFocusLoss();
virtual Uint32 onMessage( const NodeMessage* msg );
virtual void updateScroll(); virtual void updateScroll();
void syncScrollOffset();
virtual void onContentSizeChange(); virtual void onContentSizeChange();
@@ -770,6 +770,7 @@ bool TerminalDisplay::update( bool isMouseOverMe ) {
if ( !( mWindow->getInput()->getPressTrigger() & EE_BUTTON_LMASK ) ) { if ( !( mWindow->getInput()->getPressTrigger() & EE_BUTTON_LMASK ) ) {
mWindow->getInput()->captureMouse( false ); mWindow->getInput()->captureMouse( false );
mDraggingSel = false; mDraggingSel = false;
mSelectionOverridesMouseCapture = false;
} else if ( !isMouseOverMe ) { } else if ( !isMouseOverMe ) {
onMouseMove( mWindow->getInput()->getMousePos(), onMouseMove( mWindow->getInput()->getMousePos(),
mWindow->getInput()->getPressTrigger() ); mWindow->getInput()->getPressTrigger() );
@@ -809,6 +810,8 @@ void TerminalDisplay::consumeSnapshot() {
return; return;
const Vector2i previousCursor = mCursor; 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 ) || const bool dimensionsChanged = snapshot->columns != static_cast<int>( mColumns ) ||
snapshot->rows != static_cast<int>( mRows ); snapshot->rows != static_cast<int>( mRows );
if ( dimensionsChanged ) { if ( dimensionsChanged ) {
@@ -857,6 +860,13 @@ void TerminalDisplay::consumeSnapshot() {
mMode |= MODE_BLINK; mMode |= MODE_BLINK;
mClock.restart(); 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; mDirty = true;
} }
@@ -871,9 +881,6 @@ void TerminalDisplay::drainSessionEvents() {
case TerminalSession::EventType::IconTitle: case TerminalSession::EventType::IconTitle:
sendEvent( { EventType::ICON_TITLE, std::move( event.data ) } ); sendEvent( { EventType::ICON_TITLE, std::move( event.data ) } );
break; break;
case TerminalSession::EventType::ScrollPosition:
sendEvent( { EventType::SCROLL_HISTORY } );
break;
case TerminalSession::EventType::Bell: case TerminalSession::EventType::Bell:
sendEvent( { EventType::BELL } ); sendEvent( { EventType::BELL } );
break; break;
@@ -906,9 +913,6 @@ void TerminalDisplay::drainSessionEvents() {
Log::error( "Terminal worker error: %s", event.data.c_str() ); Log::error( "Terminal worker error: %s", event.data.c_str() );
sendEvent( { EventType::WORKER_ERROR, std::move( event.data ) } ); sendEvent( { EventType::WORKER_ERROR, std::move( event.data ) } );
break; break;
case TerminalSession::EventType::HistoryLength:
sendEvent( { EventType::HISTORY_LENGTH_CHANGE } );
break;
case TerminalSession::EventType::SnapshotReady: case TerminalSession::EventType::SnapshotReady:
break; break;
} }
@@ -1139,9 +1143,13 @@ void TerminalDisplay::onMouseDoubleClick( const Vector2i& pos, const Uint32& fla
} }
void TerminalDisplay::onMouseMove( const Vector2i& pos, const Uint32& flags ) { 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(); 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 ) && if ( !isAltScr() && !isCapturingMouse && ( flags & EE_BUTTON_LMASK ) &&
mAlreadyClickedLButton ) { mAlreadyClickedLButton ) {
@@ -1163,17 +1171,22 @@ void TerminalDisplay::onMouseMove( const Vector2i& pos, const Uint32& flags ) {
( mDraggingSel || getSelectionMode() == SEL_EMPTY || getSelectionMode() == SEL_READY ) ) { ( mDraggingSel || getSelectionMode() == SEL_EMPTY || getSelectionMode() == SEL_READY ) ) {
auto gridPos{ positionToGrid( pos ) }; auto gridPos{ positionToGrid( pos ) };
mSession->selectionExtend( mSession->selectionExtend(
gridPos.x, gridPos.y, gridPos.x, gridPos.y, modifiers & KEYMOD_SHIFT ? SEL_RECTANGULAR : SEL_REGULAR, false );
mWindow->getInput()->getModState() & 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 ) { void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
bool shiftPressed = ( mWindow->getInput()->getModState() & KEYMOD_SHIFT ) != 0; const Uint32 modifiers = mWindow->getInput()->getModState();
bool isCapturingMouse = isAppCapturingMouse() && !shiftPressed; 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 ) if ( ( flags & EE_BUTTON_LMASK ) && mDraggingSel )
return; return;
@@ -1187,6 +1200,7 @@ void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
if ( !mDraggingSel ) { if ( !mDraggingSel ) {
mSession->selectionStart( gridPos.x, gridPos.y, 0 ); mSession->selectionStart( gridPos.x, gridPos.y, 0 );
mDraggingSel = true; mDraggingSel = true;
mSelectionOverridesMouseCapture = selectionOverride;
invalidateLines(); invalidateLines();
mWindow->getInput()->captureMouse( true ); mWindow->getInput()->captureMouse( true );
} }
@@ -1208,11 +1222,18 @@ void TerminalDisplay::onMouseDown( const Vector2i& pos, const Uint32& flags ) {
} }
} }
mSession->mouseReport( TerminalMouseEventType::MouseButtonDown, positionToGrid( pos ), if ( !selectionOverride ) {
positionToPixel( pos ), flags, mWindow->getInput()->getModState() ); mSession->mouseReport( TerminalMouseEventType::MouseButtonDown, positionToGrid( pos ),
positionToPixel( pos ), flags, modifiers );
}
} }
void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) { 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 ) { if ( ( flags & EE_BUTTON_LMASK ) && mDraggingSel ) {
mDraggingSel = false; mDraggingSel = false;
} }
@@ -1221,11 +1242,12 @@ void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) {
mWindow->getClipboard()->setPrimarySelectionText( getSelection() ); mWindow->getClipboard()->setPrimarySelectionText( getSelection() );
} }
Uint32 smod = sanitizeMod( mWindow->getInput()->getModState() ); Uint32 smod = sanitizeMod( modifiers );
if ( flags & EE_BUTTON_LMASK ) { if ( flags & EE_BUTTON_LMASK ) {
mAlreadyClickedLButton = false; mAlreadyClickedLButton = false;
mWindow->getInput()->captureMouse( false ); mWindow->getInput()->captureMouse( false );
mSelectionOverridesMouseCapture = false;
} }
if ( flags & EE_BUTTON_MMASK ) if ( flags & EE_BUTTON_MMASK )
@@ -1255,8 +1277,10 @@ void TerminalDisplay::onMouseUp( const Vector2i& pos, const Uint32& flags ) {
} }
} }
mSession->mouseReport( TerminalMouseEventType::MouseButtonRelease, positionToGrid( pos ), if ( !selectionOverride ) {
positionToPixel( pos ), flags, mWindow->getInput()->getModState() ); mSession->mouseReport( TerminalMouseEventType::MouseButtonRelease, positionToGrid( pos ),
positionToPixel( pos ), flags, modifiers );
}
} }
static inline Color termColor( unsigned int terminalColor, const std::vector<Color>& colors ) { static inline Color termColor( unsigned int terminalColor, const std::vector<Color>& colors ) {
@@ -947,28 +947,33 @@ size_t TerminalEmulator::ttyread( void ) {
mDataCb( mBuf + mBuflen, ret ); mDataCb( mBuf + mBuflen, ret );
int old_scr = mTerm.scr; 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; mTerm.scr = 0;
mBuflen += ret; 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 ); written = twrite( mBuf, mBuflen, 0 );
mProcessingPtyInput = false;
mBuflen -= written; mBuflen -= written;
/* keep any incomplete UTF-8 byte sequence for the next call */ /* keep any incomplete UTF-8 byte sequence for the next call */
if ( mBuflen > 0 ) if ( mBuflen > 0 )
memmove( mBuf, mBuf + written, mBuflen ); memmove( mBuf, mBuf + written, mBuflen );
if ( old_scr > 0 ) { if ( old_scr > 0 ) {
int lines_pushed = 0; const int lines_pushed = mPtyHistoryLinesPushed;
if ( mTerm.histsize > 0 ) {
lines_pushed = ( mTerm.histi - old_histi + mTerm.histsize ) % mTerm.histsize;
}
mTerm.scr = eemin( mTerm.histlen, old_scr + lines_pushed ); mTerm.scr = eemin( mTerm.histlen, old_scr + lines_pushed );
if ( lines_pushed > 0 ) { selmove( mTerm.scr );
mSel.ob.y += lines_pushed; if ( mTerm.scr != old_scr )
mSel.oe.y += lines_pushed;
onScrollPositionChange(); onScrollPositionChange();
}
} }
return ret; return ret;
@@ -1025,7 +1030,9 @@ void TerminalEmulator::kscrollto( const TerminalArg* a ) {
int n = a->i; int n = a->i;
if ( 0 <= n && n <= mTerm.histlen ) { if ( 0 <= n && n <= mTerm.histlen ) {
int delta = n - mTerm.scr;
mTerm.scr = n; mTerm.scr = n;
selmove( delta );
tfulldirt(); tfulldirt();
onScrollPositionChange(); onScrollPositionChange();
} }
@@ -1117,10 +1124,17 @@ bool TerminalEmulator::isScrolling() const {
} }
void TerminalEmulator::ttywrite( const char* s, size_t n, int may_echo ) { 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; const char* next;
TerminalArg arg = { (int)mTerm.scr }; if ( scrollToBottom ) {
kscrolldown( &arg ); TerminalArg arg = { (int)mTerm.scr };
kscrolldown( &arg );
}
if ( may_echo && IS_SET( MODE_ECHO ) ) if ( may_echo && IS_SET( MODE_ECHO ) )
twrite( s, (int)n, 1 ); twrite( s, (int)n, 1 );
@@ -1149,6 +1163,22 @@ static Uint32 keyboardSanitizeMod( Uint32 mod ) {
return mod & KEYMOD_CTRL_SHIFT_ALT_META; 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 ) { static char legacyControlCharacter( Scancode scancode ) {
if ( scancode >= SCANCODE_A && scancode <= SCANCODE_Z ) if ( scancode >= SCANCODE_A && scancode <= SCANCODE_Z )
return static_cast<char>( scancode - SCANCODE_A + 1 ); 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 ); const auto enhanced = KittyKeyboardEncoder::encode( event, flags );
if ( enhanced.handled ) { 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; mExpectedTextInput = enhanced.expectedText;
return; return;
} }
@@ -1470,6 +1501,8 @@ void TerminalEmulator::tscrollup( int top, int n, int copyhist ) {
for ( i = 0; i < n; i++ ) for ( i = 0; i < n; i++ )
historyStealPush( &mTerm.line[top + i], mTerm.col ); historyStealPush( &mTerm.line[top + i], mTerm.col );
if ( mProcessingPtyInput )
mPtyHistoryLinesPushed += n;
if ( attop ) if ( attop )
mTerm.scr = mTerm.histlen; mTerm.scr = mTerm.histlen;
@@ -3874,8 +3907,9 @@ void TerminalEmulator::drawregion( ITerminalDisplay& dpy, int x1, int y1, int x2
void TerminalEmulator::draw() { void TerminalEmulator::draw() {
// DEC private mode 2026 makes the bytes between DECSET and DECRST one presentation unit. // 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. // PTY parsing can also temporarily force scr to zero while updating the live screen. Neither
if ( mTerm.is_syncing ) // state is a stable presentation boundary, so no partial frame or viewport may reach the UI.
if ( mTerm.is_syncing || mProcessingPtyInput )
return; return;
int cx = mTerm.c.x /*, ocx = term.ocx, ocy = term.ocy*/; int cx = mTerm.c.x /*, ocx = term.ocx, ocy = term.ocy*/;
@@ -135,12 +135,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
if ( auto* process = mEmulator->getProcess() ) if ( auto* process = mEmulator->getProcess() )
snapshot->processId = process->pid(); 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 ) ); mSession.publishSnapshot( std::move( snapshot ) );
} }
@@ -239,8 +233,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
mSession.enqueueEvent( std::move( event ), false ); mSession.enqueueEvent( std::move( event ), false );
} }
void onScrollPositionChange() { mSession.enqueueEvent( { EventType::ScrollPosition }, true ); }
void setPalette( TerminalColorPalette palette ) { void setPalette( TerminalColorPalette palette ) {
mInitialPalette = palette; mInitialPalette = palette;
mPalette = std::move( palette ); mPalette = std::move( palette );
@@ -284,7 +276,6 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay {
TerminalGlyph mCursorGlyph; TerminalGlyph mCursorGlyph;
int mColumns{ 0 }; int mColumns{ 0 };
int mRows{ 0 }; int mRows{ 0 };
int mLastHistoryLength{ -1 };
Uint32 mPresentationRate{ 60 }; Uint32 mPresentationRate{ 60 };
bool mCursorVisible{ false }; bool mCursorVisible{ false };
}; };
@@ -498,10 +489,9 @@ void TerminalSession::enqueueEvent( Event event, bool coalescable ) {
std::lock_guard<std::mutex> lock( mEventMutex ); std::lock_guard<std::mutex> lock( mEventMutex );
if ( coalescable ) { if ( coalescable ) {
for ( auto it = mEvents.rbegin(); it != mEvents.rend(); ++it ) { for ( auto it = mEvents.rbegin(); it != mEvents.rend(); ++it ) {
const bool replaceable = const bool replaceable = it->type == EventType::Title ||
it->type == EventType::Title || it->type == EventType::IconTitle || it->type == EventType::IconTitle ||
it->type == EventType::HistoryLength || it->type == EventType::ScrollPosition || it->type == EventType::SnapshotReady;
it->type == EventType::SnapshotReady;
if ( !replaceable ) if ( !replaceable )
break; break;
if ( it->type == event.type ) { if ( it->type == event.type ) {
+33 -10
View File
@@ -93,7 +93,11 @@ UITerminal::UITerminal( const std::shared_ptr<TerminalDisplay>& terminalDisplay
syncFontRenderingConfig(); syncFontRenderingConfig();
registerNewTerminal(); registerNewTerminal();
mVScroll->setParent( this ); 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", setCommand( "terminal-scroll-up-screen",
[this] { mTerm->action( TerminalShortcutAction::SCROLLUP_SCREEN ); } ); [this] { mTerm->action( TerminalShortcutAction::SCROLLUP_SCREEN ); } );
@@ -162,7 +166,9 @@ void UITerminal::onContentSizeChange() {
mPendingContentSizeChange = false; mPendingContentSizeChange = false;
updateScrollPosition(); updateScrollPosition();
mVScroll->setPageStep( contentSize > 0 ? ( visibleArea / (Float)contentSize ) : 1.f ); 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 { const ScrollBarMode& UITerminal::getVerticalScrollMode() const {
@@ -225,15 +231,19 @@ int UITerminal::getScrollableArea() const {
} }
void UITerminal::updateScroll() { void UITerminal::updateScroll() {
int totalScroll = getScrollableArea();
int initScroll( mScrollOffset ); int initScroll( mScrollOffset );
syncScrollOffset();
if ( initScroll != mScrollOffset )
onScrollChange();
}
void UITerminal::syncScrollOffset() {
int totalScroll = getScrollableArea();
mScrollOffset = 0; mScrollOffset = 0;
if ( mVScroll->isVisible() && totalScroll > 0 ) if ( mVScroll->isVisible() && totalScroll > 0 )
mScrollOffset = totalScroll * mVScroll->getValue(); mScrollOffset = totalScroll * mVScroll->getValue();
if ( initScroll != mScrollOffset )
onScrollChange();
} }
void UITerminal::onScrollChange() { void UITerminal::onScrollChange() {
@@ -531,8 +541,7 @@ Uint32 UITerminal::onKeyUp( const KeyEvent& event ) {
Uint32 UITerminal::onMouseMove( const Vector2i& position, const Uint32& flags ) { Uint32 UITerminal::onMouseMove( const Vector2i& position, const Uint32& flags ) {
if ( mViewType == ScrollViewType::Overlay && ScrollBarMode::Auto == mVScrollMode ) { if ( mViewType == ScrollViewType::Overlay && ScrollBarMode::Auto == mVScrollMode ) {
mMouseClock.restart(); mMouseClock.restart();
bool visible = bool visible = !mTerm->isAltScr() && getContentSize() > getVisibleArea();
!mTerm->isAltScr() && getContentSize() > getVisibleArea() && !mTerm->hasSelection();
mVScroll->setVisible( visible )->setEnabled( visible ); mVScroll->setVisible( visible )->setEnabled( visible );
} }
@@ -647,12 +656,26 @@ Uint32 UITerminal::onFocus( NodeFocusReason reason ) {
Uint32 UITerminal::onFocusLoss() { Uint32 UITerminal::onFocusLoss() {
getUISceneNode()->getWindow()->stopTextInput(); getUISceneNode()->getWindow()->stopTextInput();
mTerm->clearSuppressedKeys(); Node* focusNode = getEventDispatcher()->getFocusNode();
mTerm->setFocus( false ); const bool scrollBarFocus = focusNode == mVScroll || mVScroll->isParentOf( focusNode );
if ( !scrollBarFocus ) {
mTerm->clearSuppressedKeys();
mTerm->setFocus( false );
}
invalidateDraw(); invalidateDraw();
return UIWidget::onFocusLoss(); 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 ) { void UITerminal::createDefaultContextMenuOptions( UIPopUpMenu* menu ) {
if ( !mCreateDefaultContextMenuOptions ) if ( !mCreateDefaultContextMenuOptions )
return; return;
+165 -1
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 ) ); 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 ) { UTEST( eterm_session, replaceable_events_coalesce_without_losing_ordered_events ) {
auto pty = std::make_unique<MockPty>(); auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>(); auto process = std::make_unique<MockProcess>();
@@ -421,6 +443,7 @@ class MockDisplay : public ITerminalDisplay {
public: public:
int mDrawLines{ 0 }; int mDrawLines{ 0 };
int mDrawEnds{ 0 }; int mDrawEnds{ 0 };
std::vector<int> mPublishedScrollPositions;
uint32_t mFirstMode{ 0 }; uint32_t mFirstMode{ 0 };
uint32_t mSecondMode{ 0 }; uint32_t mSecondMode{ 0 };
TerminalGlyph mFirstGlyph; TerminalGlyph mFirstGlyph;
@@ -440,7 +463,11 @@ class MockDisplay : public ITerminalDisplay {
} }
} }
void drawCursor( int, int, TerminalGlyph, int, int, TerminalGlyph ) override {} 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 resetColors() override { ++mResetColorsCount; }
void drawGraphics( std::shared_ptr<TerminalGraphicsPresentation> presentation, void drawGraphics( std::shared_ptr<TerminalGraphicsPresentation> presentation,
std::vector<TerminalGraphicsUpdate> ) override { 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 ); 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 ) { UTEST( eterm, kitty_keyboard_protocol_preserves_altgr_text ) {
auto pty = std::make_unique<MockPty>(); auto pty = std::make_unique<MockPty>();
pty->mBuffer = "\033[>15u"; pty->mBuffer = "\033[>15u";
@@ -1464,6 +1519,33 @@ UTEST( eterm, synchronized_updates_publish_only_complete_frames ) {
EXPECT_STDSTREQ( "complete", term->getSelection() ); 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 ) { UTEST( eterm, sgr_colon_subparameters_preserve_groups_and_optional_color_space ) {
auto pty = std::make_unique<MockPty>(); auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>(); auto process = std::make_unique<MockProcess>();
@@ -2272,6 +2354,88 @@ UTEST( eterm, scroll_position_after_ttyread ) {
EXPECT_STDSTREQ( "New output", term->getSelection() ); 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 ) { UTEST( eterm, history_corruption_on_resize ) {
auto pty = std::make_unique<MockPty>(); auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>(); auto process = std::make_unique<MockProcess>();