Skip to content

Commit db5d0f5

Browse files
leftspinMike Manzano
andauthored
Fix scroll() to respect left/right margins (DECSLRM) (#434)
* Fix scroll() to respect left/right margins (DECSLRM) When margin mode is active with narrowed left/right margins, the scroll() function was scrolling entire lines instead of only the columns within the margin region. This caused display corruption when applications like tmux use DECSLRM for side-by-side panes - scrolling in one pane would affect content in adjacent panes. The fix adds a new code path that detects when: 1. Margin mode is enabled (DECLRMM) 2. Margins are narrower than full width 3. Cursor is within the margin region In this case, we do in-place column-by-column scrolling within the margin bounds, similar to how cmdInsertLines/cmdDeleteLines already handle this scenario. The isWrapped flag is cleared on the bottom line since partial-line scrolling breaks line continuity. When margins are full width or cursor is outside margins, normal scrollback behavior is preserved. * Add margin checks to cmdIndex and reverseIndex Both functions now respect left/right margins (DECSLRM) like cmdLineFeedBasic already does: - cmdIndex (ESC D): Only scroll if cursor is within margin columns - reverseIndex (ESC M): Only scroll if cursor is within margin columns, and do column-wise scrolling for narrow margins This fixes intermittent display corruption in tmux side-by-side panes where Index or Reverse Index commands could affect content outside their margin boundaries. --------- Co-authored-by: Mike Manzano <mike@clicketyclacks.co>
1 parent e57d886 commit db5d0f5

1 file changed

Lines changed: 97 additions & 19 deletions

File tree

Sources/SwiftTerm/Terminal.swift

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4959,11 +4959,17 @@ open class Terminal {
49594959
func cmdIndex ()
49604960
{
49614961
restrictCursor()
4962-
4962+
49634963
let buffer = self.buffer
49644964
let newY = buffer.y + 1
4965+
4966+
// When left/right margins are active, only scroll if cursor is within margins
4967+
let canScroll = buffer.x >= buffer.marginLeft && buffer.x <= buffer.marginRight
4968+
49654969
if newY > buffer.scrollBottom {
4966-
scroll ()
4970+
if canScroll {
4971+
scroll ()
4972+
}
49674973
} else {
49684974
buffer.y = newY
49694975
}
@@ -4988,7 +4994,45 @@ open class Terminal {
49884994
let topRow = buffer.yBase + buffer.scrollTop
49894995
let bottomRow = buffer.yBase + buffer.scrollBottom
49904996

4991-
if buffer.scrollTop == 0 {
4997+
// When margin mode is active with left/right margins that are narrower than full width,
4998+
// we cannot use scrollback (can't push partial lines), so we do in-place scrolling
4999+
// within the margin columns only. This path is unconditional when narrow margins are
5000+
// active, regardless of cursor position, to ensure consistent behavior.
5001+
let hasNarrowMargins = marginMode && (buffer.marginLeft > 0 || buffer.marginRight < cols - 1)
5002+
if hasNarrowMargins {
5003+
let scrollRegionHeight = bottomRow - topRow + 1
5004+
let columnCount = buffer.marginRight - buffer.marginLeft + 1
5005+
let ea = eraseAttr()
5006+
5007+
// Shift content up within the margin columns only.
5008+
//
5009+
// LIMITATION: Line-level metadata (isWrapped, images, renderMode) cannot be
5010+
// partially scrolled, so we reset them on all affected lines.
5011+
//
5012+
// Ideally, isWrapped would be tracked per-column-range so that triple-click
5013+
// selection in one pane selects the wrapped logical line within that pane only.
5014+
// However, isWrapped is currently a per-BufferLine property (spanning all columns),
5015+
// so there's no way to represent "wrapped in cols 0-39, not wrapped in cols 40-79".
5016+
// Implementing column-aware wrapping would require architectural changes to the
5017+
// data model. For now, we clear isWrapped since partial-column scrolling breaks
5018+
// the line-level wrapping semantic.
5019+
//
5020+
for i in 0..<(scrollRegionHeight - 1) {
5021+
let src = buffer.lines[topRow + i + 1]
5022+
let dst = buffer.lines[topRow + i]
5023+
dst.copyFrom(src, srcCol: buffer.marginLeft, dstCol: buffer.marginLeft, len: columnCount)
5024+
dst.isWrapped = false
5025+
dst.images = nil
5026+
dst.renderMode = .single
5027+
}
5028+
5029+
// Clear the bottom row within the margin columns.
5030+
let bottomLine = buffer.lines[bottomRow]
5031+
bottomLine.fill(with: CharData(attribute: ea), atCol: buffer.marginLeft, len: columnCount)
5032+
bottomLine.isWrapped = false
5033+
bottomLine.images = nil
5034+
bottomLine.renderMode = .single
5035+
} else if buffer.scrollTop == 0 {
49925036
// Determine whether the buffer is going to be trimmed after insertion.
49935037
let willBufferBeTrimmed = buffer.lines.isFull
49945038

@@ -5016,7 +5060,7 @@ open class Terminal {
50165060
if buffer.hasScrollback {
50175061
buffer.linesTop += 1
50185062
}
5019-
5063+
50205064
// When the buffer is full and the user has scrolled up, keep the text
50215065
// stable unless ydisp is right at the top
50225066
if userScrolling {
@@ -5444,25 +5488,59 @@ open class Terminal {
54445488
{
54455489
let buffer = self.buffer
54465490
restrictCursor()
5491+
5492+
// When left/right margins are active, only scroll if cursor is within margins
5493+
let canScroll = buffer.x >= buffer.marginLeft && buffer.x <= buffer.marginRight
5494+
54475495
if buffer.y == buffer.scrollTop {
5448-
// possibly move the code below to term.reverseScroll()
5449-
// test: echo -ne '\e[1;1H\e[44m\eM\e[0m'
5450-
// blankLine(true) is xterm/linux behavior
5451-
let startIndex = buffer.y + buffer.yBase
5452-
let scrollRegionHeight = buffer.scrollBottom - buffer.scrollTop
5496+
if canScroll {
5497+
// possibly move the code below to term.reverseScroll()
5498+
// test: echo -ne '\e[1;1H\e[44m\eM\e[0m'
5499+
// blankLine(true) is xterm/linux behavior
5500+
let topRow = buffer.yBase + buffer.scrollTop
5501+
let bottomRow = buffer.yBase + buffer.scrollBottom
5502+
5503+
// Ensure the start index is within bounds to prevent crash (issue #256)
5504+
// This can happen when the buffer has been trimmed and yBase is stale
5505+
guard topRow < buffer.lines.count else {
5506+
print ("reverseIndex: start index \(topRow) >= lines.count \(buffer.lines.count), state: y=\(buffer.y) yBase=\(buffer.yBase) scrollTop=\(buffer.scrollTop) scrollBottom=\(buffer.scrollBottom) isAlternate=\(isCurrentBufferAlternate)")
5507+
return
5508+
}
54535509

5454-
// Ensure the start index is within bounds to prevent crash (issue #256)
5455-
// This can happen when the buffer has been trimmed and yBase is stale
5456-
guard startIndex < buffer.lines.count else {
5457-
print ("reverseIndex: start index \(startIndex) >= lines.count \(buffer.lines.count), state: y=\(buffer.y) yBase=\(buffer.yBase) scrollTop=\(buffer.scrollTop) scrollBottom=\(buffer.scrollBottom) isAlternate=\(isCurrentBufferAlternate)")
5458-
return
5459-
}
5510+
let hasNarrowMargins = marginMode && (buffer.marginLeft > 0 || buffer.marginRight < cols - 1)
5511+
5512+
if hasNarrowMargins {
5513+
// Do in-place reverse scrolling within margin columns only
5514+
let scrollRegionHeight = bottomRow - topRow + 1
5515+
let columnCount = buffer.marginRight - buffer.marginLeft + 1
5516+
let ea = eraseAttr()
54605517

5461-
if !buffer.lines.shiftElements (start: startIndex, count: scrollRegionHeight, offset: 1) {
5462-
print ("Assertion on reverseIndex, state was: y=\(buffer.y) scrollTop=\(buffer.scrollTop) yDisp=\(buffer.yDisp) linesTop=\(buffer.linesTop) isAlternate=\(isCurrentBufferAlternate)")
5518+
// Shift content down within the margin columns (reverse of scroll)
5519+
for i in stride(from: scrollRegionHeight - 1, through: 1, by: -1) {
5520+
let src = buffer.lines[topRow + i - 1]
5521+
let dst = buffer.lines[topRow + i]
5522+
dst.copyFrom(src, srcCol: buffer.marginLeft, dstCol: buffer.marginLeft, len: columnCount)
5523+
dst.isWrapped = false
5524+
dst.images = nil
5525+
dst.renderMode = .single
5526+
}
5527+
5528+
// Clear the top row within the margin columns
5529+
let topLine = buffer.lines[topRow]
5530+
topLine.fill(with: CharData(attribute: ea), atCol: buffer.marginLeft, len: columnCount)
5531+
topLine.isWrapped = false
5532+
topLine.images = nil
5533+
topLine.renderMode = .single
5534+
} else {
5535+
// Full-width scrolling - use original shiftElements approach
5536+
let scrollRegionHeight = buffer.scrollBottom - buffer.scrollTop
5537+
if !buffer.lines.shiftElements (start: topRow, count: scrollRegionHeight, offset: 1) {
5538+
print ("Assertion on reverseIndex, state was: y=\(buffer.y) scrollTop=\(buffer.scrollTop) yDisp=\(buffer.yDisp) linesTop=\(buffer.linesTop) isAlternate=\(isCurrentBufferAlternate)")
5539+
}
5540+
buffer.lines [topRow] = buffer.getBlankLine (attribute: eraseAttr ())
5541+
}
5542+
updateRange (startLine: buffer.scrollTop, endLine: buffer.scrollBottom)
54635543
}
5464-
buffer.lines [startIndex] = buffer.getBlankLine (attribute: eraseAttr ())
5465-
updateRange (startLine: buffer.scrollTop, endLine: buffer.scrollBottom)
54665544
} else if buffer.y > 0 {
54675545
buffer.y -= 1
54685546
}

0 commit comments

Comments
 (0)