Skip to content

Commit dcd9a32

Browse files
eyelockDavid Collieclaudemigueldeicaza
authored
Translate selections when rows move in place (#616)
* Translate selections when lines are scrolled in place A selection is anchored to absolute buffer rows. When a scroll pushes lines into the scrollback that is safe: the rows stay valid and yDisp advances. But when an application sets a scroll region that does not start at the top of the screen (DECSTBM), Terminal.scroll shifts the lines within the region instead, leaving yDisp untouched. The selection anchors are then left pointing at whatever text scrolled into those rows, so the highlight sits over the wrong text and copying returns it. Full-screen TUIs hit this constantly: GitHub Copilot CLI, for example, reserves its tab bar and prompt rows and scrolls the transcript between them with ESC[4;34r followed by a linefeed, so any selection made while output is streaming silently drifts onto other lines. Selections now register with their terminal and are translated when lines shift in place, and are cleared when they scroll out of the region. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Cover the remaining in-place row movements Selections also need translating when rows move without going through the scrollback on the other paths that shift lines in place: - reverseIndex (ESC M) shifts rows down within the scroll region - cmdInsertLines (CSI L) pushes rows below the cursor down - cmdDeleteLines (CSI M) pulls rows below the cursor up Margin mode (DECSLRM) shifts only the columns between the margins, and a selection cannot be represented as partially shifted, so on those paths an overlapping selection is cleared instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Complete selection translation for row scrolling --------- Co-authored-by: David Collie <support@eyelock.net> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel de Icaza <miguel@gnome.org>
1 parent db838cc commit dcd9a32

3 files changed

Lines changed: 431 additions & 0 deletions

File tree

Sources/SwiftTerm/SelectionService.swift

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,104 @@ class SelectionService: CustomDebugStringConvertible {
2424
end = Position(col: 0, row: 0)
2525
pivot = Position(col: 0, row: 0)
2626
hasSelectionRange = false
27+
terminal.register (selection: self)
28+
}
29+
30+
/**
31+
* Translates the selection when the terminal shifts lines in place, which
32+
* happens when an application scrolls a region set with DECSTBM that does
33+
* not start at the top of the screen. Those scrolls do not push lines into
34+
* the scrollback, so `yDisp` does not move and the absolute rows the
35+
* selection is anchored to end up holding different text.
36+
*
37+
* Rows outside the scrolled region keep their position. A selection is
38+
* dropped if it scrolls out of the region or crosses a region boundary.
39+
* In those cases, the original text is gone or is no longer contiguous.
40+
*/
41+
func adjustForInPlaceScroll (top: Int, bottom: Int, lines: Int)
42+
{
43+
guard active, lines != 0 else {
44+
return
45+
}
46+
47+
let (first, last) = Position.compare (start, end) == .before ? (start, end) : (end, start)
48+
let intersectsRegion = first.row <= bottom && last.row >= top
49+
guard intersectsRegion else {
50+
return
51+
}
52+
guard first.row >= top && last.row <= bottom else {
53+
selectNone ()
54+
return
55+
}
56+
57+
func translate (_ position: Position) -> Position? {
58+
guard position.row >= top && position.row <= bottom else {
59+
return position
60+
}
61+
let newRow = position.row - lines
62+
guard newRow >= top && newRow <= bottom else {
63+
return nil
64+
}
65+
return Position (col: position.col, row: newRow)
66+
}
67+
68+
guard let newStart = translate (start), let newEnd = translate (end) else {
69+
selectNone ()
70+
return
71+
}
72+
73+
let newPivot: Position?
74+
if let pivot, pivot == start || pivot == end {
75+
guard let translatedPivot = translate (pivot) else {
76+
selectNone ()
77+
return
78+
}
79+
newPivot = translatedPivot
80+
} else {
81+
newPivot = pivot
82+
}
83+
84+
let newWordSelectionAnchor: (start: Position, end: Position)?
85+
if let wordSelectionAnchor {
86+
guard let translatedStart = translate (wordSelectionAnchor.start),
87+
let translatedEnd = translate (wordSelectionAnchor.end) else {
88+
selectNone ()
89+
return
90+
}
91+
newWordSelectionAnchor = (translatedStart, translatedEnd)
92+
} else {
93+
newWordSelectionAnchor = nil
94+
}
95+
96+
start = newStart
97+
end = newEnd
98+
pivot = newPivot
99+
wordSelectionAnchor = newWordSelectionAnchor
100+
terminal.tdel?.selectionChanged (source: terminal)
101+
}
102+
103+
/**
104+
* Clears the selection if it overlaps a region whose contents were shifted
105+
* only within a range of columns, which happens when margin mode narrows
106+
* the scrolled area (DECSLRM). A selection cannot be represented as
107+
* partially shifted, so the honest answer is to drop it.
108+
*/
109+
func invalidateForColumnRestrictedScroll (top: Int, bottom: Int, left: Int, right: Int)
110+
{
111+
guard active else {
112+
return
113+
}
114+
115+
let (first, last) = Position.compare (start, end) == .before ? (start, end) : (end, start)
116+
guard first.row <= bottom && last.row >= top else {
117+
return
118+
}
119+
// A single-row selection that sits entirely outside the margin columns
120+
// is unaffected; anything spanning rows crosses them by definition.
121+
if first.row == last.row && (last.col < left || first.col > right) {
122+
return
123+
}
124+
selectNone ()
27125
}
28126

29127
/**

Sources/SwiftTerm/Terminal.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,41 @@ open class Terminal {
333333
/// Setup(isReset:) method should be called to apply changes
334334
public var options: TerminalOptions
335335

336+
// Selection services attached to this terminal. Held weakly: the views own
337+
// them. They are notified when lines are shifted in place so they can
338+
// translate their anchors (see `adjustForInPlaceScroll`).
339+
private struct WeakSelection {
340+
weak var value: SelectionService?
341+
}
342+
private var selections: [WeakSelection] = []
343+
344+
func register (selection: SelectionService)
345+
{
346+
selections.removeAll { $0.value == nil }
347+
guard !selections.contains (where: { $0.value === selection }) else {
348+
return
349+
}
350+
selections.append (WeakSelection (value: selection))
351+
}
352+
353+
/// Notifies attached selections that `lines` rows were shifted up in place
354+
/// within the absolute row range `top...bottom`.
355+
func selectionsAdjustForInPlaceScroll (top: Int, bottom: Int, lines: Int)
356+
{
357+
for entry in selections {
358+
entry.value?.adjustForInPlaceScroll (top: top, bottom: bottom, lines: lines)
359+
}
360+
}
361+
362+
/// Notifies attached selections that rows `top...bottom` were shifted only
363+
/// within the columns `left...right` (margin mode).
364+
func selectionsInvalidateForColumnRestrictedScroll (top: Int, bottom: Int, left: Int, right: Int)
365+
{
366+
for entry in selections {
367+
entry.value?.invalidateForColumnRestrictedScroll (top: top, bottom: bottom, left: left, right: right)
368+
}
369+
}
370+
336371
// The current buffers
337372
var normalBuffer, altBuffer: Buffer
338373
/**
@@ -2525,8 +2560,11 @@ open class Terminal {
25252560
let last = buffer.lines [row]
25262561
last.fill (with: CharData (attribute: ea), atCol: buffer.marginLeft, len: columnCount)
25272562
}
2563+
2564+
selectionsInvalidateForColumnRestrictedScroll (top: row, bottom: row + rowCount, left: buffer.marginLeft, right: buffer.marginRight)
25282565
}
25292566
} else {
2567+
let inserted = p
25302568
for _ in 0..<p {
25312569
p -= 1
25322570
// test: echo -e '\e[44m\e[1L\e[0m'
@@ -2536,6 +2574,9 @@ open class Terminal {
25362574
let newLine = buffer.getBlankLine (attribute: ea)
25372575
buffer.lines.splice (start: row, deleteCount: 0, items: [newLine], change: { line in updateRange (line) })
25382576
}
2577+
2578+
// Rows below the cursor moved down in place.
2579+
selectionsAdjustForInPlaceScroll (top: row, bottom: scrollBottomAbsolute - 1, lines: -inserted)
25392580
}
25402581
// this.maxRange();
25412582
updateRange (startLine: buffer.y, endLine: buffer.scrollBottom)
@@ -4765,6 +4806,8 @@ open class Terminal {
47654806
let last = buffer.lines [row]
47664807
last.fill (with: CharData (attribute: da), atCol: buffer.marginLeft, len: columnCount)
47674808
}
4809+
4810+
selectionsInvalidateForColumnRestrictedScroll (top: row, bottom: row + rowCount, left: buffer.marginLeft, right: buffer.marginRight)
47684811
} else {
47694812
for _ in 0..<p {
47704813
buffer.lines.splice (start: buffer.yBase + buffer.scrollBottom, deleteCount: 1,
@@ -4773,6 +4816,10 @@ open class Terminal {
47734816
items: [buffer.getBlankLine (attribute: da)],
47744817
change: { line in updateRange (line) })
47754818
}
4819+
4820+
let top = buffer.yBase + buffer.scrollTop
4821+
let bottom = buffer.yBase + buffer.scrollBottom
4822+
selectionsAdjustForInPlaceScroll (top: top, bottom: bottom, lines: -p)
47764823
}
47774824
// this.maxRange();
47784825
refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false)
@@ -4801,6 +4848,8 @@ open class Terminal {
48014848
let last = buffer.lines [row+rowCount]
48024849
last.fill (with: CharData (attribute: da), atCol: buffer.marginLeft, len: columnCount)
48034850
}
4851+
4852+
selectionsInvalidateForColumnRestrictedScroll (top: row, bottom: row + rowCount, left: buffer.marginLeft, right: buffer.marginRight)
48044853
} else {
48054854
for _ in 0..<p {
48064855
buffer.lines.splice (start: buffer.yBase + buffer.scrollTop, deleteCount: 1,
@@ -4809,6 +4858,10 @@ open class Terminal {
48094858
items: [buffer.getBlankLine (attribute: da)],
48104859
change: { line in updateRange (line) })
48114860
}
4861+
4862+
let top = buffer.yBase + buffer.scrollTop
4863+
let bottom = buffer.yBase + buffer.scrollBottom
4864+
selectionsAdjustForInPlaceScroll (top: top, bottom: bottom, lines: p)
48124865
}
48134866
// this.maxRange();
48144867
refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false)
@@ -4872,6 +4925,8 @@ open class Terminal {
48724925
let last = buffer.lines [row+rowCount]
48734926
last.fill (with: CharData (attribute: ea), atCol: buffer.marginLeft, len: columnCount)
48744927
}
4928+
4929+
selectionsInvalidateForColumnRestrictedScroll (top: row, bottom: row + rowCount, left: buffer.marginLeft, right: buffer.marginRight)
48754930
}
48764931
} else {
48774932
if buffer.y >= buffer.scrollTop && buffer.y <= buffer.scrollBottom {
@@ -4883,6 +4938,9 @@ open class Terminal {
48834938
items: [buffer.getBlankLine (attribute: ea)],
48844939
change: { line in updateRange (line)})
48854940
}
4941+
4942+
// Rows below the cursor moved up in place.
4943+
selectionsAdjustForInPlaceScroll (top: row, bottom: j, lines: p)
48864944
}
48874945
}
48884946

@@ -5351,6 +5409,8 @@ open class Terminal {
53515409
bottomLine.isWrapped = false
53525410
buffer.clearImagesFromLine(at: bottomRow)
53535411
bottomLine.renderMode = .single
5412+
5413+
selectionsInvalidateForColumnRestrictedScroll (top: topRow, bottom: bottomRow, left: bMarginLeft, right: bMarginRight)
53545414
} else if scrollTop == 0 {
53555415
// Determine whether the buffer is going to be trimmed after insertion.
53565416
let willBufferBeTrimmed = lines.isFull
@@ -5381,6 +5441,10 @@ open class Terminal {
53815441
buffer.linesTop += 1
53825442
}
53835443

5444+
// Recycling removes the first buffer row and shifts every
5445+
// remaining row up without changing yDisp.
5446+
selectionsAdjustForInPlaceScroll (top: 0, bottom: lines.count - 1, lines: 1)
5447+
53845448
// When the buffer is full and the user has scrolled up, keep the text
53855449
// stable unless ydisp is right at the top
53865450
if userScrolling {
@@ -5405,6 +5469,10 @@ open class Terminal {
54055469
}
54065470
}
54075471
lines [bottomRow] = BufferLine (from: newLine)
5472+
5473+
// The rows moved but yDisp did not, so any selection anchored to
5474+
// absolute rows in this region now points at different text.
5475+
selectionsAdjustForInPlaceScroll (top: topRow, bottom: bottomRow, lines: 1)
54085476
}
54095477

54105478
// Move the viewport to the bottom of the buffer unless the user is
@@ -5844,13 +5912,18 @@ open class Terminal {
58445912
topLine.isWrapped = false
58455913
buffer.clearImagesFromLine(at: topRow)
58465914
topLine.renderMode = .single
5915+
5916+
selectionsInvalidateForColumnRestrictedScroll (top: topRow, bottom: bottomRow, left: buffer.marginLeft, right: buffer.marginRight)
58475917
} else {
58485918
// Full-width scrolling - use original shiftElements approach
58495919
let scrollRegionHeight = buffer.scrollBottom - buffer.scrollTop
58505920
if !buffer.lines.shiftElements (start: topRow, count: scrollRegionHeight, offset: 1) {
58515921
print ("Assertion on reverseIndex, state was: y=\(buffer.y) scrollTop=\(buffer.scrollTop) yDisp=\(buffer.yDisp) linesTop=\(buffer.linesTop) isAlternate=\(isCurrentBufferAlternate)")
58525922
}
58535923
buffer.lines [topRow] = buffer.getBlankLine (attribute: eraseAttr ())
5924+
5925+
// Lines moved down in place; translate selections with them.
5926+
selectionsAdjustForInPlaceScroll (top: topRow, bottom: bottomRow, lines: -1)
58545927
}
58555928
refreshScrolledRegion(top: buffer.scrollTop, bottom: buffer.scrollBottom, canBlit: false)
58565929
}

0 commit comments

Comments
 (0)